summaryrefslogtreecommitdiffstats
path: root/testing/web-platform/tests/layout-instability
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /testing/web-platform/tests/layout-instability
parentInitial commit. (diff)
downloadfirefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz
firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/web-platform/tests/layout-instability')
-rw-r--r--testing/web-platform/tests/layout-instability/META.yml4
-rw-r--r--testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-contain.html31
-rw-r--r--testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-negative-overflow.html31
-rw-r--r--testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-overflow.html31
-rw-r--r--testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-will-change.html31
-rw-r--r--testing/web-platform/tests/layout-instability/add-remove-position-fixed.html35
-rw-r--r--testing/web-platform/tests/layout-instability/add-remove-position-sticky.html30
-rw-r--r--testing/web-platform/tests/layout-instability/body-display-change.html51
-rw-r--r--testing/web-platform/tests/layout-instability/buffer-layout-shift.html45
-rw-r--r--testing/web-platform/tests/layout-instability/buffered-flag.html41
-rw-r--r--testing/web-platform/tests/layout-instability/child-shift-with-parent-overflow-hidden.html32
-rw-r--r--testing/web-platform/tests/layout-instability/child-shift-with-parent-overflow-x-clip.html30
-rw-r--r--testing/web-platform/tests/layout-instability/child-shift-with-parent.html30
-rw-r--r--testing/web-platform/tests/layout-instability/clip-negative-bottom-margin.html36
-rw-r--r--testing/web-platform/tests/layout-instability/composited-element-movement.html52
-rw-r--r--testing/web-platform/tests/layout-instability/contain-paint-fully-clipped.html26
-rw-r--r--testing/web-platform/tests/layout-instability/content-visibility-auto-offscreen.html47
-rw-r--r--testing/web-platform/tests/layout-instability/content-visibility-auto-onscreen.html27
-rw-r--r--testing/web-platform/tests/layout-instability/content-visibility-auto-resize.html31
-rw-r--r--testing/web-platform/tests/layout-instability/content-visibility-hidden.html27
-rw-r--r--testing/web-platform/tests/layout-instability/display-change-with-transform.html33
-rw-r--r--testing/web-platform/tests/layout-instability/expand-above-viewport.html49
-rw-r--r--testing/web-platform/tests/layout-instability/fixed-position-move.html27
-rw-r--r--testing/web-platform/tests/layout-instability/fully-clipped-visual-rect.html32
-rw-r--r--testing/web-platform/tests/layout-instability/idlharness.html50
-rw-r--r--testing/web-platform/tests/layout-instability/ignore-fixed-and-sticky.html54
-rw-r--r--testing/web-platform/tests/layout-instability/inline-flow-shift-one-line.html41
-rw-r--r--testing/web-platform/tests/layout-instability/inline-flow-shift-vertical-rl.html44
-rw-r--r--testing/web-platform/tests/layout-instability/inline-flow-shift.html42
-rw-r--r--testing/web-platform/tests/layout-instability/input-timestamp.html72
-rw-r--r--testing/web-platform/tests/layout-instability/local-shift-without-viewport-shift-2.html32
-rw-r--r--testing/web-platform/tests/layout-instability/local-shift-without-viewport-shift.html32
-rw-r--r--testing/web-platform/tests/layout-instability/main-frame.html64
-rw-r--r--testing/web-platform/tests/layout-instability/mousemove-becomes-drag.html62
-rw-r--r--testing/web-platform/tests/layout-instability/move-distance-clamped.html48
-rw-r--r--testing/web-platform/tests/layout-instability/move-transformed.html28
-rw-r--r--testing/web-platform/tests/layout-instability/multi-clip-visual-rect.html36
-rw-r--r--testing/web-platform/tests/layout-instability/multicol-000.html22
-rw-r--r--testing/web-platform/tests/layout-instability/multicol-001.html26
-rw-r--r--testing/web-platform/tests/layout-instability/opacity-nonzero-to-zero.html25
-rw-r--r--testing/web-platform/tests/layout-instability/opacity-zero-layout-and-visible.html31
-rw-r--r--testing/web-platform/tests/layout-instability/opacity-zero.html31
-rw-r--r--testing/web-platform/tests/layout-instability/outline.html21
-rw-r--r--testing/web-platform/tests/layout-instability/partially-clipped-visual-rect.html32
-rw-r--r--testing/web-platform/tests/layout-instability/pointerdown-becomes-scroll.html62
-rw-r--r--testing/web-platform/tests/layout-instability/pointerdown-becomes-tap.html60
-rw-r--r--testing/web-platform/tests/layout-instability/pointermove-becomes-drag.html69
-rw-r--r--testing/web-platform/tests/layout-instability/recent-input.html61
-rw-r--r--testing/web-platform/tests/layout-instability/resources/slow-image.py6
-rw-r--r--testing/web-platform/tests/layout-instability/resources/test-adapter.js5
-rw-r--r--testing/web-platform/tests/layout-instability/resources/util.js97
-rw-r--r--testing/web-platform/tests/layout-instability/rtl-distance.html31
-rw-r--r--testing/web-platform/tests/layout-instability/shift-into-viewport-inline-direction-and-scroll.html29
-rw-r--r--testing/web-platform/tests/layout-instability/shift-into-viewport-inline-direction.html27
-rw-r--r--testing/web-platform/tests/layout-instability/shift-into-viewport.html36
-rw-r--r--testing/web-platform/tests/layout-instability/shift-invisible.html22
-rw-r--r--testing/web-platform/tests/layout-instability/shift-outside-viewport-inline-direction.html27
-rw-r--r--testing/web-platform/tests/layout-instability/shift-outside-viewport.html34
-rw-r--r--testing/web-platform/tests/layout-instability/shift-scroll-anchoring-natural-scroll.html65
-rw-r--r--testing/web-platform/tests/layout-instability/shift-while-scrolled.html37
-rw-r--r--testing/web-platform/tests/layout-instability/shift-with-counter-scroll-and-transform.html62
-rw-r--r--testing/web-platform/tests/layout-instability/shift-with-counter-scroll-and-translate.html62
-rw-r--r--testing/web-platform/tests/layout-instability/shift-with-counterscroll-2.html59
-rw-r--r--testing/web-platform/tests/layout-instability/shift-with-counterscroll.html54
-rw-r--r--testing/web-platform/tests/layout-instability/shift-with-overflow-status-change.html33
-rw-r--r--testing/web-platform/tests/layout-instability/simple-block-movement.html31
-rw-r--r--testing/web-platform/tests/layout-instability/sources-enclosure.html62
-rw-r--r--testing/web-platform/tests/layout-instability/sources-maximpact.html73
-rw-r--r--testing/web-platform/tests/layout-instability/sources.html38
-rw-r--r--testing/web-platform/tests/layout-instability/sticky-descendant-move.html29
-rw-r--r--testing/web-platform/tests/layout-instability/sticky-layout-no-change.html26
-rw-r--r--testing/web-platform/tests/layout-instability/sub-frame.html50
-rw-r--r--testing/web-platform/tests/layout-instability/supported-layout-type.html18
-rw-r--r--testing/web-platform/tests/layout-instability/toJSON.html48
-rw-r--r--testing/web-platform/tests/layout-instability/transform-above-filter-dynamic.html22
-rw-r--r--testing/web-platform/tests/layout-instability/transform-above-perspective-dynamic.html24
-rw-r--r--testing/web-platform/tests/layout-instability/transform-change.html33
-rw-r--r--testing/web-platform/tests/layout-instability/transform-counter-layout-shift.html36
-rw-r--r--testing/web-platform/tests/layout-instability/transform.html37
-rw-r--r--testing/web-platform/tests/layout-instability/translate-change.html33
-rw-r--r--testing/web-platform/tests/layout-instability/translate-counter-layout-shift.html36
-rw-r--r--testing/web-platform/tests/layout-instability/video.html32
-rw-r--r--testing/web-platform/tests/layout-instability/visibility-hidden-layout-and-visible.html33
-rw-r--r--testing/web-platform/tests/layout-instability/visibility-hidden.html24
-rw-r--r--testing/web-platform/tests/layout-instability/visible-to-hidden.html25
85 files changed, 3250 insertions, 0 deletions
diff --git a/testing/web-platform/tests/layout-instability/META.yml b/testing/web-platform/tests/layout-instability/META.yml
new file mode 100644
index 0000000000..10c6aa36ce
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/META.yml
@@ -0,0 +1,4 @@
+spec: https://wicg.github.io/layout-instability/
+suggested_reviewers:
+ - skobes
+ - npm1
diff --git a/testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-contain.html b/testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-contain.html
new file mode 100644
index 0000000000..749cfe172a
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-contain.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<title>Layout Instability: parent and contained absolute child moved together</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div id="parent" style="position: relative; width: 100px; height: 100px; background: yellow">
+ <div id="child" style="position: absolute; width: 100px; height: 100px; background: blue"></div>
+</div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ const parent = document.querySelector("#parent");
+ parent.style.top = '100px';
+
+ // Parent contains child, so score only once.
+ const expectedScore = computeExpectedScore(100 * (100 + 100), 100);
+
+ // Observer fires after the frame is painted.
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Parent and contained absolute child movement.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-negative-overflow.html b/testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-negative-overflow.html
new file mode 100644
index 0000000000..21caa263c9
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-negative-overflow.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<title>Layout Instability: parent and overflowing absolute child moved together</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div id="parent" style="position: relative; left: 400px; top: 300px; width: 100px; height: 100px; background: yellow">
+ <div id="child" style="position: absolute; top: -300px; left: -400px; width: 100px; height: 100px; background: blue"></div>
+</div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ const parent = document.querySelector("#parent");
+ parent.style.top = '400px';
+
+ // We should track parent and child separately.
+ const expectedScore = computeExpectedScore(100 * (100 + 100), 100) * 2;
+
+ // Observer fires after the frame is painted.
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Parent and overflowing absolute child movement.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-overflow.html b/testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-overflow.html
new file mode 100644
index 0000000000..24dda875c7
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-overflow.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<title>Layout Instability: parent and overflowing absolute child moved together</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div id="parent" style="position: relative; width: 100px; height: 100px; background: yellow">
+ <div id="child" style="position: absolute; top: 300px; left: 400px; width: 100px; height: 100px; background: blue"></div>
+</div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ const parent = document.querySelector("#parent");
+ parent.style.top = '100px';
+
+ // We should track parent and child separately.
+ const expectedScore = computeExpectedScore(100 * (100 + 100), 100) * 2;
+
+ // Observer fires after the frame is painted.
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Parent and overflowing absolute child movement.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-will-change.html b/testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-will-change.html
new file mode 100644
index 0000000000..1597f10892
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/absolute-child-shift-with-parent-will-change.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<title>Layout Instability: parent and overflowing absolute child moved together</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div id="parent" style="position: relative; width: 100px; height: 100px; background: yellow; will-change: transform">
+ <div id="child" style="position: absolute; top: 300px; left: 400px; width: 100px; height: 100px; background: blue"></div>
+</div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ const parent = document.querySelector("#parent");
+ parent.style.top = '100px';
+
+ // We should track parent and child separately.
+ const expectedScore = computeExpectedScore(100 * (100 + 100), 100) * 2;
+
+ // Observer fires after the frame is painted.
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Parent and overflowing absolute child movement.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/add-remove-position-fixed.html b/testing/web-platform/tests/layout-instability/add-remove-position-fixed.html
new file mode 100644
index 0000000000..19a109e8d7
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/add-remove-position-fixed.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<title>Layout Instability: no shift for adding/removing position:fixed</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<div id="target" style="width: 100px; height: 100px; background: green;
+ position: absolute; top: 200px"></div>
+<div style="height: 2000px"></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ window.scrollTo(0, 1000);
+ target.style.position = 'fixed';
+ target.style.top = 0;
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+
+ window.scrollTo(0, 100);
+ target.style.position = 'absolute';
+ target.style.top = '200px';
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "No shift for adding/removing position:fixed.");
+
+</script>
+
+
diff --git a/testing/web-platform/tests/layout-instability/add-remove-position-sticky.html b/testing/web-platform/tests/layout-instability/add-remove-position-sticky.html
new file mode 100644
index 0000000000..a269dd1569
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/add-remove-position-sticky.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<title>Layout Instability: no shift for adding/removing position:sticky</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<div id="target" style="width: 100px; height: 100px; background: green"></div>
+<div style="height: 2000px"></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ target.style.position = 'sticky';
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+
+ target.style.position = 'static';
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "No shift for adding/removing position:sticky.");
+
+</script>
+
+
diff --git a/testing/web-platform/tests/layout-instability/body-display-change.html b/testing/web-platform/tests/layout-instability/body-display-change.html
new file mode 100644
index 0000000000..0576bd6865
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/body-display-change.html
@@ -0,0 +1,51 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift accompanied by body display change</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+body { margin: 0; }
+#cont {
+ background: white;
+ width: 300px;
+ height: 200px;
+}
+#ch {
+ background: blue;
+ position: relative;
+ width: 300px;
+ height: 100px;
+ top: 100px;
+}
+
+</style>
+<style id="s"></style>
+<div id="cont">
+ <div id="ch"></div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+ await waitForAnimationFrames(2);
+
+ document.querySelector("#s").innerHTML = `
+ body {
+ display: flex;
+ flex-direction: column;
+ }
+ #ch { top: 0; }
+ `;
+
+ // An element of size (300 x 100) has shifted by 100px.
+ const expectedScore = computeExpectedScore(300 * (100 + 100), 100);
+
+ // Observer fires after the frame is painted.
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Shift accompanied by body display change.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/buffer-layout-shift.html b/testing/web-platform/tests/layout-instability/buffer-layout-shift.html
new file mode 100644
index 0000000000..1db0452497
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/buffer-layout-shift.html
@@ -0,0 +1,45 @@
+<!DOCTYPE HTML>
+<meta charset=utf-8>
+<title>Layout Instability entries are not available via the performance timeline</title>
+<body>
+<style>
+#myDiv { position: relative; width: 300px; height: 100px; background: blue; }
+</style>
+<div id='myDiv'></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+promise_test(async t => {
+ assert_implements(window.LayoutShift, 'Layout Instability is not supported.');
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ const startTime = performance.now();
+ return new Promise(resolve => {
+ new PerformanceObserver(t.step_func(list => {
+ const endTime = performance.now();
+ assert_equals(list.getEntries().length, 1);
+ const entry = list.getEntries()[0];
+ assert_equals(entry.entryType, "layout-shift");
+ assert_equals(entry.name, "");
+ assert_greater_than_equal(entry.startTime, startTime);
+ assert_less_than_equal(entry.startTime, endTime);
+ assert_equals(entry.duration, 0.0);
+ // The layout shift value should be:
+ // 300 * (100 + 60) * (60 / maxDimension) / viewport size.
+ assert_equals(entry.value, computeExpectedScore(300 * (100 + 60), 60));
+
+ // The entry should not be available via getEntries* methods.
+ assert_equals(performance.getEntriesByType('layout-shift').length, 0, 'getEntriesByType should have no layout-shift entries');
+ assert_equals(performance.getEntriesByName('', 'layout-shift').length, 0, 'getEntriesByName should have no layout-shift entries');
+ assert_equals(performance.getEntries().filter(e => e.entryType === 'layout-shift').length, 0, 'getEntries should have no layout-shift entries');
+ resolve();
+ })).observe({type: 'layout-shift'});
+ // Modify the position of the div.
+ document.getElementById('myDiv').style = "top: 60px";
+ });
+}, 'Layout shift before onload is not buffered into the performance timeline.');
+</script>
+
+</body>
diff --git a/testing/web-platform/tests/layout-instability/buffered-flag.html b/testing/web-platform/tests/layout-instability/buffered-flag.html
new file mode 100644
index 0000000000..a2e91797ce
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/buffered-flag.html
@@ -0,0 +1,41 @@
+<!DOCTYPE HTML>
+<meta charset=utf-8>
+<title>Layout Instability: PerformanceObserver sees entries with buffered flag</title>
+<body>
+<style>
+#myDiv { position: relative; width: 300px; height: 100px; background: blue; }
+</style>
+<div id='myDiv'></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+promise_test(async t => {
+ assert_implements(window.LayoutShift, 'Layout Instability is not supported.');
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ const startTime = performance.now();
+ return new Promise(resolve => {
+ // First observer creates second in callback to ensure the entry has been dispatched by the time
+ // the second observer begins observing.
+ new PerformanceObserver(() => {
+ const endTime = performance.now();
+ // Second observer requires 'buffered: true' to see entries.
+ new PerformanceObserver(t.step_func(list => {
+ assert_equals(list.getEntries().length, 1);
+ const entry = list.getEntries()[0];
+ assert_equals(entry.entryType, "layout-shift");
+ assert_greater_than_equal(entry.startTime, startTime);
+ assert_less_than_equal(entry.startTime, endTime);
+ assert_equals(entry.duration, 0.0);
+ assert_equals(entry.value, computeExpectedScore(300 * (100 + 60), 60));
+ resolve();
+ })).observe({'type': 'layout-shift', buffered: true});
+ }).observe({type: 'layout-shift'});
+ // Modify the position of the div to cause a layout-shift entry.
+ document.getElementById('myDiv').style = "top: 60px";
+ });
+}, 'PerformanceObserver with buffered flag sees previous layout-shift entry.');
+</script>
+</body>
diff --git a/testing/web-platform/tests/layout-instability/child-shift-with-parent-overflow-hidden.html b/testing/web-platform/tests/layout-instability/child-shift-with-parent-overflow-hidden.html
new file mode 100644
index 0000000000..f7e9e022b9
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/child-shift-with-parent-overflow-hidden.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<title>Layout Instability: parent (with overflow:hidden) and child moved together</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div id="parent" style="position: relative; width: 200px; height: 200px;
+ border: 50px solid blue; overflow: hidden">
+ <div id="child" style="width: 400px; height: 400px; background: blue"></div>
+</div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ const parent = document.querySelector("#parent");
+ parent.style.top = '100px';
+
+ // Only the parent area should be reported.
+ const expectedScore = computeExpectedScore(300 * (300 + 100), 100);
+
+ // Observer fires after the frame is painted.
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Parent (with overflow:hidden) and child moved together.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/child-shift-with-parent-overflow-x-clip.html b/testing/web-platform/tests/layout-instability/child-shift-with-parent-overflow-x-clip.html
new file mode 100644
index 0000000000..7a85d16668
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/child-shift-with-parent-overflow-x-clip.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<title>Layout Instability: parent/child moved together with overflow-x: clip</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div id="parent" style="position: relative; width: 100px; height: 100px; border: 100px solid blue; overflow-x: clip">
+ <div id="child" style="width: 1000px; height: 300px; background: blue"></div>
+</div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ const parent = document.querySelector("#parent");
+ parent.style.top = '100px';
+
+ const expectedScore = computeExpectedScore(300 * (400 + 100), 100);
+
+ // Observer fires after the frame is painted.
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Parent/child movement with overflow-x: clip.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/child-shift-with-parent.html b/testing/web-platform/tests/layout-instability/child-shift-with-parent.html
new file mode 100644
index 0000000000..e23bfd0c94
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/child-shift-with-parent.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<title>Layout Instability: parent/child moved together</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div id="parent" style="position: relative; width: 100px; height: 100px; border: 100px solid blue">
+ <div id="child" style="height: 300px"></div>
+</div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ const parent = document.querySelector("#parent");
+ parent.style.top = '100px';
+
+ const expectedScore = computeExpectedScore(300 * (400 + 100), 100);
+
+ // Observer fires after the frame is painted.
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Parent/child movement.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/clip-negative-bottom-margin.html b/testing/web-platform/tests/layout-instability/clip-negative-bottom-margin.html
new file mode 100644
index 0000000000..2c329d9fcd
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/clip-negative-bottom-margin.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<title>Layout Instability: clip with negative bottom margin</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+#scroller { overflow: scroll; width: 200px; height: 500px; }
+#space { height: 1000px; margin-bottom: -500px; }
+#j { width: 150px; height: 150px; background: yellow; }
+
+</style>
+<div id='scroller'>
+ <div id='space'></div>
+ <div id='j'></div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Increase j's top margin by 100px. Since j is fully clipped by the scroller,
+ // this should not generate a shift.
+ document.querySelector("#j").style.marginTop = "100px";
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "Clip with negative bottom margin.");
+
+</script>
+
+
diff --git a/testing/web-platform/tests/layout-instability/composited-element-movement.html b/testing/web-platform/tests/layout-instability/composited-element-movement.html
new file mode 100644
index 0000000000..c990690335
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/composited-element-movement.html
@@ -0,0 +1,52 @@
+<!DOCTYPE html>
+<title>Layout Instability: element with compositing layer hint</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+#shift {
+ position: relative;
+ width: 500px;
+ height: 200px;
+ background: yellow;
+}
+#container {
+ position: absolute;
+ width: 350px;
+ height: 400px;
+ right: 50px;
+ top: 100px;
+ background: #ccc;
+}
+.promote { will-change: transform; }
+
+</style>
+<div id="container" class="promote">
+ <div id="space"></div>
+ <div id="shift" class="promote"></div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Induce a shift.
+ document.querySelector("#space").style = "height: 100px";
+
+ // #shift is 400x200 after viewport intersection with correct application of
+ // composited #container offset, and 100px lower after shifting, so impact
+ // region is 400 * (200 + 100).
+ const expectedScore = computeExpectedScore(400 * (200 + 100), 100);
+
+ // Observer fires after the frame is painted.
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, "Element with compositing layer hint.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/contain-paint-fully-clipped.html b/testing/web-platform/tests/layout-instability/contain-paint-fully-clipped.html
new file mode 100644
index 0000000000..3c0ec726b2
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/contain-paint-fully-clipped.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<title>Layout Instability: fully clipped by contain:paint</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<div style="contain: paint; height: 0; position: relative">
+ <div id="target" style="position: absolute; top: 0; width: 400px; height: 400px; background: blue"></div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Shift target, for which no shift should be reported because it's hidden.
+ document.querySelector("#target").style.top = '200px';
+
+ await waitForAnimationFrames(2);
+ // No shift should be reported.
+ assert_equals(watcher.score, 0);
+}, 'fully clipped by contain:paint');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/content-visibility-auto-offscreen.html b/testing/web-platform/tests/layout-instability/content-visibility-auto-offscreen.html
new file mode 100644
index 0000000000..9e4361b38b
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/content-visibility-auto-offscreen.html
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<title>Layout Instability: off-screen content-visibility:auto content</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+// These scripts need to be before the contents because we need to ensure no
+// layout shifts during page load.
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ window.scrollTo(0, 100000 + 100);
+ await waitForAnimationFrames(2);
+
+ assert_equals(watcher.score, 0);
+
+ // This should report a layout shift as target is now visible.
+ target.style.top = '100100px';
+
+ await watcher.promise;
+ const expectedScore = computeExpectedScore(100 * 100, 100);
+ assert_equals(watcher.score, expectedScore);
+
+ // No new layout shift should be reported when target is scrolled out of screeen.
+ window.scrollTo(0, 0);
+ await waitForAnimationFrames(2);
+
+ assert_equals(watcher.score, expectedScore);
+}, 'off-screen content-visibility:auto');
+</script>
+<style>
+ .auto {
+ content-visibility: auto;
+ contain-intrinsic-size: 1px;
+ width: 100px;
+ }
+</style>
+<div class=auto>
+ <div style="width: 100px; height: 100px; background: blue"></div>
+</div>
+<div id="target" class=auto style="position: relative; top: 100000px">
+ <div style="width: 100px; height: 100px; background: blue"></div>
+</div> \ No newline at end of file
diff --git a/testing/web-platform/tests/layout-instability/content-visibility-auto-onscreen.html b/testing/web-platform/tests/layout-instability/content-visibility-auto-onscreen.html
new file mode 100644
index 0000000000..a7fbd92995
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/content-visibility-auto-onscreen.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<title>Layout Instability: on-screen content-visibility:auto content</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+// These scripts need to be before the contents because we need to ensure no
+// layout shifts during page load.
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+ assert_equals(watcher.score, 0);
+}, 'on-screen content-visibility:auto');
+</script>
+<style>
+ #target {
+ content-visibility: auto;
+ contain-intrinsic-size: 1px;
+ width: 100px;
+ }
+</style>
+<div id=target>
+ <div style="width: 100px; height: 100px; background: blue"></div>
+</div>
diff --git a/testing/web-platform/tests/layout-instability/content-visibility-auto-resize.html b/testing/web-platform/tests/layout-instability/content-visibility-auto-resize.html
new file mode 100644
index 0000000000..bb2d2e5c71
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/content-visibility-auto-resize.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<title>Layout Instability: resizing content-visibility:auto content</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+// These scripts need to be before the contents because we need to ensure no
+// layout shifts during page load.
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ assert_equals(watcher.score, 0);
+}, 'off-screen content-visibility:auto');
+</script>
+<style>
+ .auto {
+ content-visibility: auto;
+ contain-intrinsic-size: 10px 3000px;
+ width: 100px;
+ }
+ .contained {
+ height: 100px;
+ background: blue;
+ }
+</style>
+<div class=auto><div class=contained></div></div>
+<div class=auto><div class=contained></div></div>
diff --git a/testing/web-platform/tests/layout-instability/content-visibility-hidden.html b/testing/web-platform/tests/layout-instability/content-visibility-hidden.html
new file mode 100644
index 0000000000..f84cc2543e
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/content-visibility-hidden.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<title>Layout Instability: content-visibility:hidden content</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+// These scripts need to be before the contents because we need to ensure no
+// layout shifts during page load.
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+ assert_equals(watcher.score, 0);
+}, 'on-screen content-visibility:auto');
+</script>
+<style>
+ #target {
+ content-visibility: hidden;
+ contain-intrinsic-size: 1px;
+ width: 100px;
+ }
+</style>
+<div id=target>
+ <div style="width: 100px; height: 100px; background: blue"></div>
+</div>
diff --git a/testing/web-platform/tests/layout-instability/display-change-with-transform.html b/testing/web-platform/tests/layout-instability/display-change-with-transform.html
new file mode 100644
index 0000000000..75eb5c5870
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/display-change-with-transform.html
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<title>Layout Instability: change display type with transform</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+ div {
+ width: 100px;
+ height: 100px;
+ background: blue;
+ }
+ #target {
+ transform: translateX(0);
+ }
+</style>
+<div id=target>
+ <div id=shift>test</div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+ await waitForAnimationFrames(2);
+
+ target.style.display = 'flex';
+
+ await waitForAnimationFrames(1);
+
+ assert_equals(watcher.score, 0);
+}, 'Shift accompanied by body display change.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/expand-above-viewport.html b/testing/web-platform/tests/layout-instability/expand-above-viewport.html
new file mode 100644
index 0000000000..f1e3f704b7
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/expand-above-viewport.html
@@ -0,0 +1,49 @@
+<!DOCTYPE html>
+<title>Layout Instability: layout shift when content expanded above the viewport</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/test-adapter.js"></script>
+<script src="resources/util.js"></script>
+<style>
+body {
+ margin: 0;
+ /* To avoid browser automatic scroll offset adjustment for the expansion. */
+ overflow-anchor: none;
+}
+</style>
+<div id="expander" style="height: 50vh"></div>
+<div id="shifted" style="width: 300px; height: 300vh; background: blue"></div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ const viewHeight = document.documentElement.clientHeight;
+ window.scrollTo(0, viewHeight);
+
+ await waitForAnimationFrames(2);
+
+ // Expander expands to push |shifted| down.
+ expander.style.height = '150vh';
+
+ const expectedScore1 = computeExpectedScore(300 * viewHeight, viewHeight);
+
+ // Observer fires after the frame is painted.
+ cls_expect(watcher, {score: 0});
+ await watcher.promise;
+ cls_expect(watcher, {score: expectedScore1});
+
+ // Expander expands to push |shifted| out of viewport.
+ expander.style.height = '200vh';
+
+ const expectedScore2 = expectedScore1 +
+ computeExpectedScore(0.5 * 300 * viewHeight, 0.5 * viewHeight);
+ await watcher.promise;
+ cls_expect(watcher, {score: expectedScore2});
+}, "Layout shift when content expanded above the viewport");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/fixed-position-move.html b/testing/web-platform/tests/layout-instability/fixed-position-move.html
new file mode 100644
index 0000000000..877c2ba4b3
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/fixed-position-move.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<title>Layout Instability: movement of fixed position</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div id="target" style="position: fixed; top: 100px; width: 300px; height: 200px; background: yellow">
+</div>
+<script>
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ target.style.top = '200px';
+
+ const expectedScore = computeExpectedScore(300 * (200 + 100), 100);
+
+ // Observer fires after the frame is painted.
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Movement of fixed position');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/fully-clipped-visual-rect.html b/testing/web-platform/tests/layout-instability/fully-clipped-visual-rect.html
new file mode 100644
index 0000000000..cf308f2634
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/fully-clipped-visual-rect.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<title>Layout Instability: fully clipped visual rect</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+body { margin: 0; }
+#clip { width: 0px; height: 600px; overflow: hidden; }
+#j { position: relative; width: 300px; height: 200px; background: blue; }
+
+</style>
+<div id='clip'><div id='j'></div></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Shift an element that is fully clipped by its container.
+ document.querySelector("#j").style.top = "200px";
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "Fully clipped visual rect.");
+
+</script>
+
+
diff --git a/testing/web-platform/tests/layout-instability/idlharness.html b/testing/web-platform/tests/layout-instability/idlharness.html
new file mode 100644
index 0000000000..ea6efb27f8
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/idlharness.html
@@ -0,0 +1,50 @@
+<!doctype html>
+<title>Layout Instability IDL tests</title>
+<meta name="timeout" content="long">
+<link rel="help" href="https://wicg.github.io/layout-instability/">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/WebIDLParser.js"></script>
+<script src="/resources/idlharness.js"></script>
+<script src="resources/util.js"></script>
+<script>
+'use strict';
+
+idl_test(
+ ['layout-instability'],
+ ['performance-timeline', 'geometry', 'dom', 'hr-time'],
+ async (idl_array, t) => {
+ idl_array.add_objects({
+ LayoutShift: ['layoutShift'],
+ LayoutShiftAttribution: ['layoutShiftAttribution'],
+ });
+
+ // If LayoutShift isn't supported, avoid the timeout below and just let the
+ // objects declared above be null. The tests will still fail, but we will
+ // consistently generate the same set of subtests on all platforms.
+ if (!PerformanceObserver ||
+ !PerformanceObserver.supportedEntryTypes ||
+ !PerformanceObserver.supportedEntryTypes.includes('layout-shift')) {
+ return;
+ }
+
+ // Make sure that the image has initially been laid out, so that the movement
+ // later is counted as a layout shift.
+ await waitForAnimationFrames(2);
+
+ self.layoutShift = await new Promise((resolve, reject) => {
+ const observer = new PerformanceObserver(entryList => {
+ resolve(entryList.getEntries()[0]);
+ });
+ observer.observe({type: 'layout-shift', buffered: true});
+ t.step_timeout(() => reject('Timed out waiting for LayoutShift entry'), 3000);
+
+ // Move the image, to cause layout shift.
+ image.style.marginTop = '100px';
+ });
+ self.layoutShiftAttribution = layoutShift.sources[0];
+ }
+);
+</script>
+
+<img id="image" src="/images/green-100x50.png">
diff --git a/testing/web-platform/tests/layout-instability/ignore-fixed-and-sticky.html b/testing/web-platform/tests/layout-instability/ignore-fixed-and-sticky.html
new file mode 100644
index 0000000000..2023dafbe0
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/ignore-fixed-and-sticky.html
@@ -0,0 +1,54 @@
+<!DOCTYPE html>
+<title>Layout Instability: ignore fixed and sticky positioning</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+body { height: 2000px; }
+#f1, #f2 {
+ position: fixed;
+ width: 300px;
+ height: 100px;
+ left: 100px;
+ background: yellow;
+}
+#f1 { top: 0; }
+#f2 { top: 150px; will-change: transform; }
+#s1 {
+ position: sticky;
+ width: 200px;
+ height: 100px;
+ left: 450px;
+ top: 0;
+ background: blue;
+}
+
+</style>
+<div id='f1'>fixed</div>
+<div id='f2'>fixed composited</div>
+<div id='s1'>sticky</div>
+normal
+
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Scroll down by 50px.
+ document.scrollingElement.scrollTop = 50;
+
+ // Force a layout.
+ document.body.style.height = "2500px";
+
+ // No layout shift should occur as a result of the scroll-triggered
+ // repositioning of fixed and sticky elements.
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, 'Ignore fixed and sticky.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/inline-flow-shift-one-line.html b/testing/web-platform/tests/layout-instability/inline-flow-shift-one-line.html
new file mode 100644
index 0000000000..0c477e0e98
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/inline-flow-shift-one-line.html
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<title>Layout Instability: inline/text movement is detected</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div style="width: 200px; font-size: 20px; line-height: 25px">
+ 1AAAAAAA<br>
+ 2AAAAAAA<br>
+ 3AAAAAAA<br>
+ <div id="inline-block" style="display: inline-block">4AAAAAAA</div><br>
+ <div id="shift" style="display: inline-block"></div>5AAAAAAA<br>
+ 6AAAAAAA<br>
+ 7AAAAAAA<br>
+</div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the width of |shift|.
+ document.querySelector("#shift").style.width = '50px';
+
+ // Only the line after |shift| is shifted right by 50px.
+ // The implementation may measure the real width of the shifted text
+ // or use the available width (i.e. width of the containing block).
+ // Also tolerate extra 10% error.
+ const text_width = document.querySelector("#inline-block").clientWidth;
+ const expectedScoreMin = computeExpectedScore((text_width + 50) * 20, 50) * 0.9;
+ const expectedScoreMax = computeExpectedScore(200 * 25, 50) * 1.1;
+
+ // Observer fires after the frame is painted.
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_between_exclusive(watcher.score, expectedScoreMin, expectedScoreMax);
+}, 'Inline flow movement.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/inline-flow-shift-vertical-rl.html b/testing/web-platform/tests/layout-instability/inline-flow-shift-vertical-rl.html
new file mode 100644
index 0000000000..848883755c
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/inline-flow-shift-vertical-rl.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<title>Layout Instability: vertical-rl inline/text movement is detected</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<body style="writing-mode: vertical-rl">
+<div style="height: 200px; font-size: 20px; line-height: 25px">
+ 1AAAAAAA<br>
+ 2AAAAAAA<br>
+ 3AAAAAAA<br>
+ <div id="inline-block" style="display: inline-block; width: 50px">4AAAAAAA</div><br>
+ 5AAAAAAA<br>
+ 6AAAAAAA<br>
+ 7AAAAAAA<br>
+</div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ const inline_block = document.querySelector("#inline-block");
+ inline_block.style.width = '100px';
+
+ // The lines below the inline-block are shifted down by 50px.
+ // The implementation may measure the real width of the shifted text
+ // or use the available width (i.e. width of the containing block).
+ // Also tolerate extra 10% error.
+ const text_width = inline_block.offsetWidth;
+ const expectedScoreMin = computeExpectedScore(text_width * (20 * 3 + 50), 50) * 0.9;
+ const expectedScoreMax = computeExpectedScore(200 * (25 * 3 + 50), 50) * 1.1;
+
+ // Observer fires after the frame is painted.
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_between_exclusive(watcher.score, expectedScoreMin, expectedScoreMax);
+}, 'Vertical-rl inline flow movement.');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/layout-instability/inline-flow-shift.html b/testing/web-platform/tests/layout-instability/inline-flow-shift.html
new file mode 100644
index 0000000000..0385f29c2f
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/inline-flow-shift.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<title>Layout Instability: inline/text movement is detected</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div style="width: 200px; font-size: 20px; line-height: 25px">
+ 1AAAAAAA<br>
+ 2AAAAAAA<br>
+ 3AAAAAAA<br>
+ <div id="inline-block" style="display: inline-block; height: 50px">4AAAAAAA</div><br>
+ 5AAAAAAA<br>
+ 6AAAAAAA<br>
+ 7AAAAAAA<br>
+</div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ const inline_block = document.querySelector("#inline-block");
+ inline_block.style.height = '100px';
+
+ // The lines below the inline-block are shifted down by 50px.
+ // The implementation may measure the real width of the shifted text
+ // or use the available width (i.e. width of the containing block).
+ // Also tolerate extra 10% error.
+ const text_width = inline_block.offsetWidth;
+ const expectedScoreMin = computeExpectedScore(text_width * (30 * 3 + 50), 50) * 0.9;
+ const expectedScoreMax = computeExpectedScore(200 * (30 * 3 + 50), 50) * 1.1;
+
+ // Observer fires after the frame is painted.
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_between_exclusive(watcher.score, expectedScoreMin, expectedScoreMax);
+}, 'Inline flow movement.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/input-timestamp.html b/testing/web-platform/tests/layout-instability/input-timestamp.html
new file mode 100644
index 0000000000..ba31d47866
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/input-timestamp.html
@@ -0,0 +1,72 @@
+<!DOCTYPE HTML>
+<meta charset=utf-8>
+<title>Layout Instability: observe timestamp after user input</title>
+
+<body>
+ <style>
+ #myDiv {
+ position: relative;
+ width: 300px;
+ height: 100px;
+ background: blue;
+ }
+
+ /* Disable the button's focus ring, which otherwise expands its visual rect by
+ * 1px on all sides, triggering a layout shift event.
+ */
+ #button {
+ outline: none;
+ }
+ </style>
+ <div id='myDiv'></div>
+ <button id='button'>Generate a 'click' event</button>
+ <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>
+ <script src=/event-timing/resources/event-timing-test-utils.js></script>
+ <script>
+ let timeAfterClick;
+
+ promise_test(async t => {
+ assert_implements(window.LayoutShift, 'Layout Instability is not supported.');
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ const startTime = performance.now();
+ return new Promise(resolve => {
+ const observer = new PerformanceObserver(
+ t.step_func(entryList => {
+ const endTime = performance.now();
+ assert_equals(entryList.getEntries().length, 1);
+ const entry = entryList.getEntries()[0];
+ assert_equals(entry.entryType, "layout-shift");
+ assert_equals(entry.name, "");
+ assert_greater_than_equal(entry.startTime, startTime);
+ assert_less_than_equal(entry.startTime, endTime);
+ assert_equals(entry.duration, 0.0);
+ // The layout shift value should be:
+ // 300 * (100 + 60) * (60 / maxDimension) / viewport size.
+ assert_equals(entry.value, computeExpectedScore(300 * (100 + 60), 60));
+ // We should see that there was a click input entry.
+ assert_equals(entry.hadRecentInput, false);
+ assert_greater_than_equal(timeAfterClick, entry.lastInputTime);
+ resolve();
+ })
+ );
+ observer.observe({ entryTypes: ['layout-shift'] });
+ // User input event
+ clickAndBlockMain('button').then(() => {
+ // 600ms delay
+ step_timeout(function() {
+ timeAfterClick = performance.now();
+ // Modify the position of the div.
+ document.getElementById('myDiv').style = "top: 60px";
+ }, 600);
+ });
+ });
+ }, 'Layout shift right after user input is observable via PerformanceObserver.');
+ </script>
+
+</body>
diff --git a/testing/web-platform/tests/layout-instability/local-shift-without-viewport-shift-2.html b/testing/web-platform/tests/layout-instability/local-shift-without-viewport-shift-2.html
new file mode 100644
index 0000000000..7074bca7ba
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/local-shift-without-viewport-shift-2.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<title>Layout Instability: local shift without viewport shift</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+#c { position: relative; width: 300px; height: 100px; scale: 0.1; }
+#j { position: relative; width: 100px; height: 10px; background: blue; }
+
+</style>
+<div id='c'>
+ <div id='j'></div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ document.querySelector("#j").style.top = "4px";
+
+ // Make sure no shift score is reported, since the element didn't move in the
+ // viewport.
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "Local shift without viewport shift.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/local-shift-without-viewport-shift.html b/testing/web-platform/tests/layout-instability/local-shift-without-viewport-shift.html
new file mode 100644
index 0000000000..d635ed5056
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/local-shift-without-viewport-shift.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<title>Layout Instability: local shift without viewport shift</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+#c { position: relative; width: 300px; height: 100px; transform: scale(0.1); }
+#j { position: relative; width: 100px; height: 10px; background: blue; }
+
+</style>
+<div id='c'>
+ <div id='j'></div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ document.querySelector("#j").style.top = "4px";
+
+ // Make sure no shift score is reported, since the element didn't move in the
+ // viewport.
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "Local shift without viewport shift.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/main-frame.html b/testing/web-platform/tests/layout-instability/main-frame.html
new file mode 100644
index 0000000000..0d0bf84ddc
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/main-frame.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<html>
+<body>
+<title>Layout Instability: subframe layout shift score</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style>
+ #i {
+ border: 0;
+ position: absolute;
+ left: 0;
+ top: 0;
+ background-color: pink;
+ }
+</style>
+<iframe id="i" width="400" height="300" src="sub-frame.html"></iframe>
+
+<script>
+const loadPromise = new Promise(resolve => {
+ window.addEventListener("load", () => {
+ resolve(true);
+ });
+});
+
+let iframe = document.getElementById('i');
+const load_promise = new Promise(resolve => {
+ iframe.addEventListener('load', function() {
+ resolve(true);
+ });
+});
+
+checkMainFrameLoad = async () => {
+ await loadPromise;
+ return true;
+};
+
+checkIFrameLoad = async () => {
+ // Wait for the iframe finishing loading
+ await load_promise;
+ return true;
+};
+
+promise_test(async t => {
+ checkMainFrameLoad();
+ // Wait for the iframe finishing loading
+ checkIFrameLoad();
+
+ // Wait for the message sent from the iframe after it receives all the layout
+ // shifts.
+ await new Promise(resolve => {
+ window.addEventListener("message", (event) => {
+ if (event.data.type == "layout shift score") {
+ t.step(() => {
+ assert_equals(event.data.score, event.data.expectedScore);
+ });
+ resolve();
+ }
+ }, false);
+ });
+}, "");
+</script>
+</body>
+</html> \ No newline at end of file
diff --git a/testing/web-platform/tests/layout-instability/mousemove-becomes-drag.html b/testing/web-platform/tests/layout-instability/mousemove-becomes-drag.html
new file mode 100644
index 0000000000..df4a416c81
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/mousemove-becomes-drag.html
@@ -0,0 +1,62 @@
+<!DOCTYPE html>
+<title>Layout Instability: no shift in mouse moves with a button pressed</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+body { margin: 0; height: 2000px; }
+#draggable {
+ top:50px;
+ left:50px;
+ width:50px;
+ height:50px;
+ background-color:blue;
+ position:absolute
+}
+
+</style>
+<div id="draggable" ></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+const draggable = document.getElementById("draggable");
+draggable.addEventListener('mousemove', function(event) {
+ // Move the element when the mouse moves.
+ draggable.style.top = event.pageY - 25 + 'px';
+ event.preventDefault();
+}, false);
+
+generateMouseMoveSequence = () => new test_driver.Actions()
+ .pointerMove(0, 0, {origin: draggable})
+ .pointerDown()
+ .pointerMove(0, 15, {origin: draggable})
+ .pause(100)
+ .pointerMove(0, 30, {origin: draggable})
+ .pause(100)
+ .pointerMove(0, 45, {origin: draggable})
+ .pause(100)
+ .pointerUp()
+ .pause(100);
+
+promise_test(async () => {
+
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Send pointer events for a sequence of mouse actions, mouse down, mouse moves and mouse up.
+ await generateMouseMoveSequence().send();
+
+ // Mouse moves which drag the objects should be counted as the excluding inputs
+ // for the layout shift.
+ assert_greater_than(watcher.score, 0);
+ assert_equals(watcher.scoreWithInputExclusion, 0);
+
+}, "No layout shift when mouse moves with a button pressed.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/move-distance-clamped.html b/testing/web-platform/tests/layout-instability/move-distance-clamped.html
new file mode 100644
index 0000000000..5854fe08d1
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/move-distance-clamped.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<title>Layout Instability: distance fraction not more than 1.0</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/test-adapter.js"></script>
+<script src="resources/util.js"></script>
+<style>
+body { margin: 0; }
+#shifter {
+ position: relative;
+ width: 100vw;
+ height: 100vh;
+ left: -2000vw;
+ top: -2000vh;
+ background: blue;
+}
+</style>
+<div id="shifter"></div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ document.querySelector("#shifter").style = "left: 0; top: 0";
+
+ const docElement = document.documentElement;
+ const viewWidth = docElement.clientWidth;
+ const viewHeight = docElement.clientHeight;
+
+ // An element the size of the viewport has shifted by a huge distance, but
+ // the move distance is effectively the viewport width or height (whichever
+ // is larger) as the distance fraction is limited to a maximum of 1.0.
+ const expectedScore = computeExpectedScore(
+ viewWidth * viewHeight,
+ Math.max(viewWidth, viewHeight));
+
+ // Observer fires after the frame is painted.
+ cls_expect(watcher, {score: 0});
+ await watcher.promise;
+ cls_expect(watcher, {score: expectedScore});
+}, "Distance fraction not more than 1.0.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/move-transformed.html b/testing/web-platform/tests/layout-instability/move-transformed.html
new file mode 100644
index 0000000000..2a7d048396
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/move-transformed.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift of a transformed container</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+body { margin: 0; }
+#transformed { position: relative; transform: translateX(20px); width: 100px; height: 100px; background: blue; }
+</style>
+<div id="transformed"></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ document.querySelector("#transformed").style = "top: 50px";
+
+ const expectedScore = computeExpectedScore(100 * (100 + 50), 50);
+
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Move transformed container');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/multi-clip-visual-rect.html b/testing/web-platform/tests/layout-instability/multi-clip-visual-rect.html
new file mode 100644
index 0000000000..9237ad833e
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/multi-clip-visual-rect.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<title>Layout Instability: multi clip visual rect</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+body { margin: 0; }
+#outer { width: 200px; height: 600px; overflow: hidden; }
+#inner { width: 300px; height: 150px; overflow: hidden; }
+#j { position: relative; width: 300px; height: 600px; background: blue; }
+
+</style>
+<div id='outer'><div id='inner'><div id='j'></div></div></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ document.querySelector("#j").style.top = "-200px";
+
+ // Note that, while the element moves up 200px, its visibility is
+ // clipped at 0px,150px height, so the additional 200px of vertical
+ // move distance is not included in the score.
+ // (clip width 200) * (clip height 150)
+ const expectedScore = computeExpectedScore(200 * 150, 200);
+
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, "Multi clip visual rect.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/multicol-000.html b/testing/web-platform/tests/layout-instability/multicol-000.html
new file mode 100644
index 0000000000..c06cddd663
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/multicol-000.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div id="multicol" style="position:relative; columns:40; width:500px; column-gap:0;">
+ <div style="height:4000px; background:black;"></div>
+</div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+ await waitForAnimationFrames(2);
+
+ multicol.style.top = '100px';
+ const expectedScore = computeExpectedScore(500 * (100 + 100), 100);
+
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Move balanced multicol container');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/multicol-001.html b/testing/web-platform/tests/layout-instability/multicol-001.html
new file mode 100644
index 0000000000..a47d5f0488
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/multicol-001.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div id="multicol" style="position:relative; columns:5; column-gap:0; column-gap:0; width:500px; height:1px;">
+ <div style="contain:size; height:100px; background:hotpink;"></div>
+ <div style="contain:size; height:100px; background:hotpink;"></div>
+ <div style="contain:size; height:100px; background:hotpink;"></div>
+ <div style="contain:size; height:100px; background:hotpink;"></div>
+ <div style="contain:size; height:100px; background:hotpink;"></div>
+</div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+ await waitForAnimationFrames(2);
+
+ multicol.style.top = '100px';
+ const expectedScore = computeExpectedScore(500 * (100 + 100), 100);
+
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Move multicol container with overflow');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/opacity-nonzero-to-zero.html b/testing/web-platform/tests/layout-instability/opacity-nonzero-to-zero.html
new file mode 100644
index 0000000000..9ce0f2be9a
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/opacity-nonzero-to-zero.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<title>Layout Instability: opacity:0</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<div id="target" style="position: absolute; top: 0; width: 400px; height: 400px; background: blue;">
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Shift target, for which no shift should be reported because it's not visible.
+ target.style.top = '200px';
+ target.style.opacity = 0;
+
+ await waitForAnimationFrames(2);
+ assert_equals(watcher.score, 0);
+}, 'opacity non-zero to zero');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/opacity-zero-layout-and-visible.html b/testing/web-platform/tests/layout-instability/opacity-zero-layout-and-visible.html
new file mode 100644
index 0000000000..0172983cea
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/opacity-zero-layout-and-visible.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<title>Layout Instability: opacity:0</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<div id="target" style="position: absolute; top: 0; width: 200px; height: 200px; opacity: 0; background: blue"></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Shift target, for which no shift should be reported because it's not visible.
+ target.style.top = '200px';
+ target.style.opacity = 0.9;
+
+ await waitForAnimationFrames(2);
+ assert_equals(watcher.score, 0);
+
+ // Shift again, for which shift should be reported.
+ target.style.top = '300px';
+
+ await watcher.promise;
+ const expectedScore = computeExpectedScore(200 * (200 + 100), 100);
+ assert_equals(watcher.score, expectedScore);
+}, 'opacity:0');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/opacity-zero.html b/testing/web-platform/tests/layout-instability/opacity-zero.html
new file mode 100644
index 0000000000..edd90800a9
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/opacity-zero.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<title>Layout Instability: opacity:0</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<div id="target" style="position: absolute; top: 0; width: 400px; height: 400px; opacity: 0; background: blue">
+ <div id="child" style="position: relative; top: 0; width: 200px; height: 200px; opacity: 0.5; background: yellow"></div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Shift target, for which no shift should be reported because it's not visible.
+ target.style.top = '200px';
+
+ await waitForAnimationFrames(2);
+ assert_equals(watcher.score, 0);
+
+ // Shift child, for which no shift should be reported, either.
+ child.style.top = '100px';
+
+ await waitForAnimationFrames(2);
+ assert_equals(watcher.score, 0);
+}, 'opacity:0');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/outline.html b/testing/web-platform/tests/layout-instability/outline.html
new file mode 100644
index 0000000000..2b3704fd87
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/outline.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<title>Layout Instability: outline doesn't contribute to layout shift</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div id="target" style="width: 300px; height: 300px; background: blue"></div>
+<script>
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Add outline for target. This should not generate a shift.
+ target.style.outline = "10px solid blue";
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "Outline.");
+</script>
diff --git a/testing/web-platform/tests/layout-instability/partially-clipped-visual-rect.html b/testing/web-platform/tests/layout-instability/partially-clipped-visual-rect.html
new file mode 100644
index 0000000000..d8be37c8bf
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/partially-clipped-visual-rect.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<title>Layout Instability: partially clipped visual rect</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+body { margin: 0; }
+#clip { width: 150px; height: 600px; overflow: hidden; }
+#j { position: relative; width: 300px; height: 200px; background: blue; }
+
+</style>
+<div id='clip'><div id='j'></div></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ document.querySelector("#j").style.top = "200px";
+
+ // (clipped width 150px) * (height 200 + movement 200)
+ const expectedScore = computeExpectedScore(150 * (200 + 200), 200);
+
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, "Partially clipped visual rect.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/pointerdown-becomes-scroll.html b/testing/web-platform/tests/layout-instability/pointerdown-becomes-scroll.html
new file mode 100644
index 0000000000..4fe5c6b7e7
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/pointerdown-becomes-scroll.html
@@ -0,0 +1,62 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift in pointerdown becoming scroll</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+body { margin: 0; height: 2000px; }
+#box {
+ left: 0px;
+ top: 0px;
+ width: 400px;
+ height: 500px;
+ background: yellow;
+ position: relative;
+}
+
+</style>
+<div id="box"></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+const box = document.querySelector("#box");
+box.addEventListener("pointerdown", (e) => {
+ // Generate a layout shift before we know what type of input this pointer
+ // event sequence will become.
+ box.style.top = "100px";
+ e.preventDefault();
+});
+
+generateScrollSequence = () => new test_driver.Actions()
+ .addPointer("tp1", "touch")
+ .pointerMove(0, 100, {sourceName: "tp1"})
+ .pointerDown({sourceName: "tp1"})
+ .pointerMove(0, 0, {sourceName: "tp1"})
+ .pause(100)
+ .pointerUp({sourceName: "tp1"})
+ .pause(100);
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Send pointer events for a touch scroll.
+ await generateScrollSequence().send();
+
+ // The box is 400 x 500 and moves by 100px.
+ const expectedScore = computeExpectedScore(400 * (500 + 100), 100);
+
+ // Both scores should increase (scroll doesn't count as input for the purpose
+ // of the LayoutShift.hadRecentInput bit).
+ assert_equals(watcher.score, expectedScore);
+ assert_equals(watcher.scoreWithInputExclusion, expectedScore);
+
+}, "Shift in pointerdown reported when it becomes a scroll.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/pointerdown-becomes-tap.html b/testing/web-platform/tests/layout-instability/pointerdown-becomes-tap.html
new file mode 100644
index 0000000000..e2e7a911dc
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/pointerdown-becomes-tap.html
@@ -0,0 +1,60 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift in pointerdown becoming tap</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+body { margin: 0; height: 2000px; }
+#box {
+ left: 0px;
+ top: 0px;
+ width: 400px;
+ height: 500px;
+ background: yellow;
+ position: relative;
+}
+
+</style>
+<div id="box"></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+const box = document.querySelector("#box");
+box.addEventListener("pointerdown", (e) => {
+ // Generate a layout shift before we know what type of input this pointer
+ // event sequence will become.
+ box.style.top = "100px";
+ e.preventDefault();
+});
+
+generateTapSequence = () => new test_driver.Actions()
+ .addPointer("tp1", "touch")
+ .pointerMove(0, 0, {sourceName: "tp1"})
+ .pointerDown({sourceName: "tp1"})
+ .pause(100)
+ .pointerUp({sourceName: "tp1"})
+ .pause(100);
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Send pointer events for a tap.
+ await generateTapSequence().send();
+
+ // The box is 400 x 500 and moves by 100px.
+ const expectedExcludedScore = computeExpectedScore(400 * (500 + 100), 100);
+
+ // Only the score that ignores hadRecentInput should increase.
+ assert_equals(watcher.score, expectedExcludedScore);
+ assert_equals(watcher.scoreWithInputExclusion, 0);
+
+}, "Shift in pointerdown excluded when it becomes a tap.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/pointermove-becomes-drag.html b/testing/web-platform/tests/layout-instability/pointermove-becomes-drag.html
new file mode 100644
index 0000000000..4bccf70423
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/pointermove-becomes-drag.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<title>Layout Instability: no shift in pointerdown becoming dragging</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+body { margin: 0; }
+#draggable {
+ top:50px;
+ left:50px;
+ width:50px;
+ height:50px;
+ background-color:blue;
+ position:absolute
+}
+
+</style>
+<div id="draggable" ></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+const draggable = document.getElementById("draggable");
+draggable.addEventListener('touchmove', function(event) {
+ let touch = event.targetTouches[0];
+
+ // Move the element when the finger moves.
+ draggable.style.top = touch.pageY - 25 + 'px';
+ event.preventDefault();
+}, false);
+
+generateTouchDragSequence = () => new test_driver.Actions()
+ .addPointer("touch1", "touch")
+ .pointerMove(0, 0, {origin: draggable})
+ .pointerDown()
+ .pointerMove(0, 15, {origin: draggable})
+ .pause(100)
+ .pointerMove(0, 30, {origin: draggable})
+ .pause(100)
+ .pointerMove(0, 45, {origin: draggable})
+ .pause(100)
+ .pointerUp()
+ .pause(100);
+
+promise_test(async(test) => {
+ const watcher = new ScoreWatcher;
+ let eventWatcher = new EventWatcher(test, draggable, ["pointerup"]);
+ let donePromise = eventWatcher.wait_for(["pointerup"], { record: 'all' });
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Send pointer events for a touch drag.
+ await generateTouchDragSequence().send();
+
+ // wait for pointerUp before running the test
+ await donePromise;
+
+ // Touch moves which drag the objects should be counted as the excluding inputs
+ // for the layout shift.
+ assert_greater_than(watcher.score, 0);
+ assert_equals(watcher.scoreWithInputExclusion, 0);
+
+}, "No Shift in pointerdown reported when it becomes a touch drag.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/recent-input.html b/testing/web-platform/tests/layout-instability/recent-input.html
new file mode 100644
index 0000000000..2779d4ffe0
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/recent-input.html
@@ -0,0 +1,61 @@
+<!DOCTYPE HTML>
+<meta charset=utf-8>
+<title>Layout Instability: observe after user input</title>
+<body>
+<style>
+#myDiv { position: relative; width: 300px; height: 100px; background: blue; }
+
+/* Disable the button's focus ring, which otherwise expands its visual rect by
+ * 1px on all sides, triggering a layout shift event.
+ */
+#button { outline: none; }
+</style>
+<div id='myDiv'></div>
+<button id='button'>Generate a 'click' event</button>
+<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>
+<script src=/event-timing/resources/event-timing-test-utils.js></script>
+<script>
+let timeAfterClick;
+
+promise_test(async t => {
+ assert_implements(window.LayoutShift, 'Layout Instability is not supported.');
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ const startTime = performance.now();
+ return new Promise(resolve => {
+ const observer = new PerformanceObserver(
+ t.step_func(entryList => {
+ const endTime = performance.now();
+ assert_equals(entryList.getEntries().length, 1);
+ const entry = entryList.getEntries()[0];
+ assert_equals(entry.entryType, "layout-shift");
+ assert_equals(entry.name, "");
+ assert_greater_than_equal(entry.startTime, startTime);
+ assert_less_than_equal(entry.startTime, endTime);
+ assert_equals(entry.duration, 0.0);
+ // The layout shift value should be:
+ // 300 * (100 + 60) * (60 / maxDimension) / viewport size.
+ assert_equals(entry.value, computeExpectedScore(300 * (100 + 60), 60));
+ // We should see that there was a click input entry.
+ assert_equals(entry.hadRecentInput, true);
+ assert_greater_than_equal(timeAfterClick, entry.lastInputTime);
+ resolve();
+ })
+ );
+ observer.observe({entryTypes: ['layout-shift']});
+ // User input event
+ clickAndBlockMain('button').then(() => {
+ timeAfterClick = performance.now();
+ // Modify the position of the div.
+ document.getElementById('myDiv').style = "top: 60px";
+ });
+ });
+}, 'Layout shift right after user input is observable via PerformanceObserver.');
+</script>
+
+</body>
diff --git a/testing/web-platform/tests/layout-instability/resources/slow-image.py b/testing/web-platform/tests/layout-instability/resources/slow-image.py
new file mode 100644
index 0000000000..d9f09b8bca
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/resources/slow-image.py
@@ -0,0 +1,6 @@
+import time
+
+def main(request, response):
+ # Sleep for 3s to delay onload.
+ time.sleep(3)
+ return [], b""
diff --git a/testing/web-platform/tests/layout-instability/resources/test-adapter.js b/testing/web-platform/tests/layout-instability/resources/test-adapter.js
new file mode 100644
index 0000000000..3272790f7a
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/resources/test-adapter.js
@@ -0,0 +1,5 @@
+// Abstracts expectations for reuse in different test frameworks.
+
+cls_expect = (watcher, expectation) => {
+ watcher.checkExpectation(expectation);
+};
diff --git a/testing/web-platform/tests/layout-instability/resources/util.js b/testing/web-platform/tests/layout-instability/resources/util.js
new file mode 100644
index 0000000000..597e2d7d84
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/resources/util.js
@@ -0,0 +1,97 @@
+// Utilities for Layout Instability tests.
+
+// Returns a promise that is resolved when the specified number of animation
+// frames has occurred.
+waitForAnimationFrames = frameCount => {
+ return new Promise(resolve => {
+ const handleFrame = () => {
+ if (--frameCount <= 0)
+ resolve();
+ else
+ requestAnimationFrame(handleFrame);
+ };
+ requestAnimationFrame(handleFrame);
+ });
+};
+
+// Returns a promise that is resolved when the next animation frame occurs.
+waitForAnimationFrame = () => waitForAnimationFrames(1);
+
+// Helper to compute an expected layout shift score based on an expected impact
+// region and max move distance for a particular animation frame.
+computeExpectedScore = (impactRegionArea, moveDistance) => {
+ const docElement = document.documentElement;
+
+ const viewWidth = docElement.clientWidth;
+ const viewHeight = docElement.clientHeight;
+
+ const viewArea = viewWidth * viewHeight;
+ const viewMaxDim = Math.max(viewWidth, viewHeight);
+
+ const impactFraction = impactRegionArea / viewArea;
+ const distanceFraction = moveDistance / viewMaxDim;
+
+ return impactFraction * distanceFraction;
+};
+
+// An list to record all the entries with startTime and score.
+let watcher_entry_record = [];
+
+// An object that tracks the document cumulative layout shift score.
+// Usage:
+//
+// const watcher = new ScoreWatcher;
+// ...
+// assert_equals(watcher.score, expectedScore);
+//
+// The score reflects only layout shifts that occur after the ScoreWatcher is
+// constructed.
+ScoreWatcher = function() {
+ if (PerformanceObserver.supportedEntryTypes.indexOf("layout-shift") == -1)
+ throw new Error("Layout Instability API not supported");
+ this.score = 0;
+ this.scoreWithInputExclusion = 0;
+ const resetPromise = () => {
+ this.promise = new Promise(resolve => {
+ this.resolve = () => {
+ resetPromise();
+ resolve();
+ }
+ });
+ };
+ resetPromise();
+ const observer = new PerformanceObserver(list => {
+ list.getEntries().forEach(entry => {
+ this.lastEntry = entry;
+ this.score += entry.value;
+ watcher_entry_record.push({startTime: entry.startTime, score: entry.value, hadRecentInput : entry.hadRecentInput});
+ if (!entry.hadRecentInput)
+ this.scoreWithInputExclusion += entry.value;
+ this.resolve();
+ });
+ });
+ observer.observe({entryTypes: ['layout-shift']});
+};
+
+ScoreWatcher.prototype.checkExpectation = function(expectation) {
+ if (expectation.score != undefined)
+ assert_equals(this.score, expectation.score);
+ if (expectation.sources)
+ check_sources(expectation.sources, this.lastEntry.sources);
+};
+
+ScoreWatcher.prototype.get_entry_record = function() {
+ return watcher_entry_record;
+};
+
+check_sources = (expect_sources, actual_sources) => {
+ assert_equals(expect_sources.length, actual_sources.length);
+ let rect_match = (e, a) =>
+ e[0] == a.x && e[1] == a.y && e[2] == a.width && e[3] == a.height;
+ let match = e => a =>
+ e.node === a.node &&
+ rect_match(e.previousRect, a.previousRect) &&
+ rect_match(e.currentRect, a.currentRect);
+ for (let e of expect_sources)
+ assert_true(actual_sources.some(match(e)), e.node + " not found");
+};
diff --git a/testing/web-platform/tests/layout-instability/rtl-distance.html b/testing/web-platform/tests/layout-instability/rtl-distance.html
new file mode 100644
index 0000000000..6635438a5e
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/rtl-distance.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<title>Layout Instability: movement distance uses starting corner</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+#shifter { position: relative; width: 100px; height: 100px; direction: rtl; background: blue; }
+
+</style>
+<div id='shifter'></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Move the left edge rightward by 10px and the right edge leftward by 20px.
+ document.querySelector("#shifter").style = "width: 70px; left: 10px";
+
+ // The movement distance should use the displacement of the right edge.
+ const expectedScore = computeExpectedScore(100 * 100, 20);
+
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'RTL element.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/shift-into-viewport-inline-direction-and-scroll.html b/testing/web-platform/tests/layout-instability/shift-into-viewport-inline-direction-and-scroll.html
new file mode 100644
index 0000000000..241fdfc57f
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/shift-into-viewport-inline-direction-and-scroll.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift into viewport in inline direction with scroll</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+#j { position: absolute; width: 200px; height: 600px; top: 300px; left: -200px; background: blue; }
+</style>
+<div id='j'></div>
+<div style="width: 5000px; height: 5000px"></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Move div into viewport horizontally.
+ document.querySelector("#j").style.left = '400px';
+ window.scrollTo(300, 300);
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "Shift into viewport in inline direction with scroll.");
+
+</script>
+
diff --git a/testing/web-platform/tests/layout-instability/shift-into-viewport-inline-direction.html b/testing/web-platform/tests/layout-instability/shift-into-viewport-inline-direction.html
new file mode 100644
index 0000000000..8847e9058b
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/shift-into-viewport-inline-direction.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift into viewport in inline direction</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+#j { position: absolute; width: 200px; height: 600px; left: -200px; background: blue; }
+</style>
+<div id='j'></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Move div into viewport horizontally.
+ document.querySelector("#j").style.left = '100px';
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "Shift into viewport in inline direction.");
+
+</script>
+
diff --git a/testing/web-platform/tests/layout-instability/shift-into-viewport.html b/testing/web-platform/tests/layout-instability/shift-into-viewport.html
new file mode 100644
index 0000000000..455abd807f
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/shift-into-viewport.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift into viewport</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+body { margin: 0; }
+#j { position: absolute; width: 600px; height: 200px; top: 100%; background: blue; }
+
+</style>
+<div id='j'></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Move div partially into viewport.
+ document.querySelector("#j").style.top =
+ document.documentElement.clientHeight - 200 + "px";
+
+ // The element moves from outside the viewport to within the viewport, which
+ // should generate a shift.
+ // (width 600) * (height 0 + move 200)
+ const expectedScore = computeExpectedScore(600 * 200, 200);
+
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, "Shift into viewport.");
+
+</script>
+
diff --git a/testing/web-platform/tests/layout-instability/shift-invisible.html b/testing/web-platform/tests/layout-instability/shift-invisible.html
new file mode 100644
index 0000000000..3c404a9438
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/shift-invisible.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift of invisible element not counted</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<div id="target" style="width: 100px; height: 100px; position: relative"></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ target.style.top = "200px";
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "Shift of invisible element not counted.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/shift-outside-viewport-inline-direction.html b/testing/web-platform/tests/layout-instability/shift-outside-viewport-inline-direction.html
new file mode 100644
index 0000000000..57a19bceec
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/shift-outside-viewport-inline-direction.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift out of viewport in inline direction</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+#j { position: absolute; width: 200px; height: 600px; background: blue; }
+</style>
+<div id='j'></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Move div out of viewport horizontally.
+ document.querySelector("#j").style.left = '-300px';
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "Shift out of viewport in inline direction.");
+
+</script>
+
diff --git a/testing/web-platform/tests/layout-instability/shift-outside-viewport.html b/testing/web-platform/tests/layout-instability/shift-outside-viewport.html
new file mode 100644
index 0000000000..534d56be4b
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/shift-outside-viewport.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift outside viewport</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+body { margin: 0; }
+#j { position: absolute; width: 600px; height: 200px; top: 100%; background: blue; }
+
+</style>
+<div id='j'></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Move div even further out of viewport.
+ document.querySelector("#j").style.top =
+ document.documentElement.clientHeight + 200 + "px";
+
+ // Since the element moves entirely outside of the viewport, it shouldn't
+ // generate a score.
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "Shift outside viewport.");
+
+</script>
+
+
diff --git a/testing/web-platform/tests/layout-instability/shift-scroll-anchoring-natural-scroll.html b/testing/web-platform/tests/layout-instability/shift-scroll-anchoring-natural-scroll.html
new file mode 100644
index 0000000000..1b146b05d7
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/shift-scroll-anchoring-natural-scroll.html
@@ -0,0 +1,65 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift offscreen with scroll anchoring and natural scroll</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+#scroller {
+ overflow: scroll;
+ left: 20px;
+ top: 20px;
+ width: 200px;
+ height: 200px;
+}
+#spacer {
+ height: 3000px;
+}
+#ch {
+ position: relative;
+ background: yellow;
+ left: 10px;
+ top: 100px;
+ width: 150px;
+ height: 150px;
+}
+#offscreenElement {
+ width: 300px;
+ height: 300px;
+ background: lightblue;
+}
+#onscreenElement {
+ width: 300px;
+ height: 300px;
+ background: lightgreen;
+}
+</style>
+<div id="scroller">
+ <div id="offscreenElement"></div>
+ <div id="spacer"></div>
+ <div id="onscreenElement"></div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Scroll to show #onscreenElement.
+ scroller.scrollTop = 3250;
+ await waitForAnimationFrames(1);
+
+ // Resize #offscreernElement and scroll a bit.
+ // Visually, #onscreenElement will move by 20px.
+ offscreenElement.style.height = '250px';
+ scroller.scrollBy(0, 20);
+
+ await waitForAnimationFrames(3);
+ // There should be no reported layout shift, because to the user it looks
+ // like a natural scroll by 20px.
+ assert_equals(watcher.score, 0);
+}, "Offscreen shift with scroll annchoring and natural scroll not counted.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/shift-while-scrolled.html b/testing/web-platform/tests/layout-instability/shift-while-scrolled.html
new file mode 100644
index 0000000000..822aa94cff
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/shift-while-scrolled.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift while scrolled</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+body { height: 2000px; margin: 0; }
+#shift { position: relative; width: 300px; height: 200px; background: blue; }
+
+</style>
+<div id="shift"></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Scroll down by 100px.
+ document.scrollingElement.scrollTop = 100;
+ assert_equals(watcher.score, 0);
+
+ // Generate a layout shift.
+ document.querySelector("#shift").style = "top: 60px";
+
+ // Impact region: width * (height - scrollTop + moveDistance)
+ const expectedScore = computeExpectedScore(
+ 300 * (200 - 100 + 60), 60);
+
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Layout shift with non-zero scroll offset.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/shift-with-counter-scroll-and-transform.html b/testing/web-platform/tests/layout-instability/shift-with-counter-scroll-and-transform.html
new file mode 100644
index 0000000000..b4e4a99c1c
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/shift-with-counter-scroll-and-transform.html
@@ -0,0 +1,62 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift with counter scroll and transform not counted</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+.scroller {
+ overflow: scroll;
+ position: absolute;
+ left: 20px;
+ top: 20px;
+ width: 200px;
+ height: 200px;
+}
+.content {
+ width: 600px;
+ height: 600px;
+}
+.changer {
+ position: relative;
+ background: yellow;
+ left: 10px;
+ top: 100px;
+ width: 150px;
+ height: 150px;
+}
+
+</style>
+<div id="scroller1" class="scroller">
+ <div class="content">
+ <div id="changer1" class="changer"></div>
+ </div>
+</div>
+<div id="scroller2" class="scroller">
+ <div class="content">
+ <div id="changer2" class="changer"></div>
+ </div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ changer1.style.top = "250px";
+ changer1.style.transform = "translateY(-50px)";
+ // 250 - 50 = 200; old position is 100; hence scrollTop to counter is 100.
+ scroller1.scrollTop = 100;
+
+ changer2.style.left = "220px";
+ changer2.style.transform = "translateX(80px)";
+ // 220 + 80 = 300; old position is 10; hence scrollTop to counter is 290.
+ scroller2.scrollLeft = 290;
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "Shift with counter scroll and transform not counted.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/shift-with-counter-scroll-and-translate.html b/testing/web-platform/tests/layout-instability/shift-with-counter-scroll-and-translate.html
new file mode 100644
index 0000000000..a51920fc98
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/shift-with-counter-scroll-and-translate.html
@@ -0,0 +1,62 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift with counter scroll and translate not counted</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+.scroller {
+ overflow: scroll;
+ position: absolute;
+ left: 20px;
+ top: 20px;
+ width: 200px;
+ height: 200px;
+}
+.content {
+ width: 600px;
+ height: 600px;
+}
+.changer {
+ position: relative;
+ background: yellow;
+ left: 10px;
+ top: 100px;
+ width: 150px;
+ height: 150px;
+}
+
+</style>
+<div id="scroller1" class="scroller">
+ <div class="content">
+ <div id="changer1" class="changer"></div>
+ </div>
+</div>
+<div id="scroller2" class="scroller">
+ <div class="content">
+ <div id="changer2" class="changer"></div>
+ </div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ changer1.style.top = "250px";
+ changer1.style.translate = "0 -50px";
+ // 250 - 50 = 200; old position is 100; hence scrollTop to counter is 100.
+ scroller1.scrollTop = 100;
+
+ changer2.style.left = "220px";
+ changer2.style.translate = "80px 0";
+ // 220 + 80 = 300; old position is 10; hence scrollTop to counter is 290.
+ scroller2.scrollLeft = 290;
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "Shift with counter scroll and translate not counted.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/shift-with-counterscroll-2.html b/testing/web-platform/tests/layout-instability/shift-with-counterscroll-2.html
new file mode 100644
index 0000000000..d99723010e
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/shift-with-counterscroll-2.html
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift with counterscroll not counted, with 2 scrollers</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+.scroller {
+ overflow: scroll;
+ position: absolute;
+ left: 20px;
+ top: 20px;
+ width: 200px;
+ height: 200px;
+}
+.content {
+ width: 170px;
+ height: 600px;
+}
+.changer {
+ position: relative;
+ background: yellow;
+ left: 10px;
+ top: 100px;
+ width: 150px;
+ height: 150px;
+}
+
+</style>
+<div id="scroller1" class="scroller">
+ <div class="content">
+ <div id="changer1" class="changer"></div>
+ </div>
+</div>
+<div id="scroller2" class="scroller">
+ <div class="content">
+ <div id="changer2" class="changer"></div>
+ </div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Top goes from 100 to 200. scroll by 100 to counter it.
+ changer1.style.top = "200px";
+ scroller1.scrollTop = 100;
+ // Top goes from 100 to 300. scroll by 200 to counter it.
+ changer2.style.top = "300px";
+ scroller2.scrollTop = 200;
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "Shift with counterscroll not counted, with 2 scrollers.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/shift-with-counterscroll.html b/testing/web-platform/tests/layout-instability/shift-with-counterscroll.html
new file mode 100644
index 0000000000..85a8ed9336
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/shift-with-counterscroll.html
@@ -0,0 +1,54 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift with counterscroll not counted</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+#s {
+ overflow: scroll;
+ position: absolute;
+ left: 20px;
+ top: 20px;
+ width: 200px;
+ height: 200px;
+}
+#sp {
+ width: 170px;
+ height: 600px;
+}
+#ch {
+ position: relative;
+ background: yellow;
+ left: 10px;
+ top: 100px;
+ width: 150px;
+ height: 150px;
+}
+
+</style>
+<div id="s">
+ <div id="sp">
+ <div id="ch"></div>
+ </div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ let scroller = document.querySelector("#s");
+ let changer = document.querySelector("#ch");
+
+ changer.style.top = "200px";
+ scroller.scrollTop = 100;
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, "Shift with counterscroll not counted.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/shift-with-overflow-status-change.html b/testing/web-platform/tests/layout-instability/shift-with-overflow-status-change.html
new file mode 100644
index 0000000000..33eea3080b
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/shift-with-overflow-status-change.html
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<title>Layout Instability: change under overflow clipping container causing shift and overflow status change at the same time</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div style="height: 100px"></div>
+<div style="overflow: auto; width: 400px; height: 400px">
+ <div id="resized" style="width: 600px; height: 100px; background: gray"></div>
+ <div id="shifted" style="width: 300px; height: 100px; background: blue"></div>
+</div>
+<script>
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ resized.style.width = '200px';
+ resized.style.height = '200px';
+
+ const expectedScore = computeExpectedScore(300 * (100 + 100), 100);
+
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+
+ resized.style.width = '600px';
+ resized.style.height = '100px';
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore * 2);
+}, 'Change under overflow clipping container causing shift and overflow status change at the same time');
+</script>
diff --git a/testing/web-platform/tests/layout-instability/simple-block-movement.html b/testing/web-platform/tests/layout-instability/simple-block-movement.html
new file mode 100644
index 0000000000..10261f7d81
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/simple-block-movement.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<title>Layout Instability: simple block movement is detected</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/test-adapter.js"></script>
+<script src="resources/util.js"></script>
+<style>
+#shifter { position: relative; width: 300px; height: 200px; background: blue; }
+</style>
+<div id="shifter"></div>
+<script>
+
+const watcher = new ScoreWatcher;
+promise_test(async () => {
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ document.querySelector("#shifter").style = "top: 160px";
+
+ // An element of size (300 x 200) has shifted by 160px.
+ const expectedScore = computeExpectedScore(300 * (200 + 160), 160);
+
+ // Observer fires after the frame is painted.
+ cls_expect(watcher, {score: 0});
+ await watcher.promise;
+ cls_expect(watcher, {score: expectedScore});
+}, 'Simple block movement.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/sources-enclosure.html b/testing/web-platform/tests/layout-instability/sources-enclosure.html
new file mode 100644
index 0000000000..8d1596ad49
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/sources-enclosure.html
@@ -0,0 +1,62 @@
+<!DOCTYPE html>
+<title>Layout Instability: source attribution with redundant enclosure</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/test-adapter.js"></script>
+<script src="resources/util.js"></script>
+<style>
+body { margin: 0; }
+#shifter {
+ position: relative; background: #def;
+ width: 300px; height: 200px;
+}
+#inner {
+ position: relative; background: #f97;
+ width: 100px; height: 100px;
+}
+#absfollow {
+ position: absolute; background: #ffd; opacity: 50%;
+ width: 350px; height: 200px; left: 0; top: 160px;
+}
+.stateB { top: 160px; }
+.stateB #inner { left: 100px; }
+.stateC ~ #absfollow { top: 0; }
+</style>
+<div id="shifter" class="stateA">
+ <div id="inner"></div>
+</div>
+<div id="absfollow"></div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+ let shifter = document.querySelector("#shifter");
+ let absfollow = document.querySelector("#absfollow");
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ shifter.className = "stateB";
+ await watcher.promise;
+
+ // Shift of #inner ignored as redundant, fully enclosed by #shifter.
+ cls_expect(watcher, {sources: [{
+ node: shifter,
+ previousRect: [0, 0, 300, 200],
+ currentRect: [0, 160, 300, 200]
+ }]});
+
+ shifter.className = "stateC";
+ await watcher.promise;
+
+ // Shift of #shifter ignored as redundant, fully enclosed by #absfollow.
+ cls_expect(watcher, {sources: [{
+ node: absfollow,
+ previousRect: [0, 160, 350, 200],
+ currentRect: [0, 0, 350, 200]
+ }]});
+
+}, "Sources with redundant enclosure.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/sources-maximpact.html b/testing/web-platform/tests/layout-instability/sources-maximpact.html
new file mode 100644
index 0000000000..497932b065
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/sources-maximpact.html
@@ -0,0 +1,73 @@
+<!DOCTYPE html>
+<title>Layout Instability: source attribution prioritization</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/test-adapter.js"></script>
+<script src="resources/util.js"></script>
+<style>
+body { margin: 0; }
+#a, #b, #c, #d, #e, #f {
+ display: inline-block;
+ background: gray;
+ min-width: 10px;
+ min-height: 10px;
+ vertical-align: top;
+}
+#a { width: 30px; height: 30px; }
+#b { width: 20px; height: 20px; }
+#c { height: 50px; }
+#d { width: 50px; }
+#e { width: 40px; height: 30px; }
+#f { width: 30px; height: 40px; }
+</style>
+<div id="grow"></div>
+<div id="a"></div
+><div id="b"></div
+><div id="c"></div
+><div id="d"></div
+><div id="e"></div
+><div id="f"></div>
+<script>
+
+let $ = id => document.querySelector(id);
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ $("#grow").style.height = "50px";
+ await watcher.promise;
+
+ cls_expect(watcher, {sources: [
+ {
+ node: $("#a"),
+ previousRect: [0, 0, 30, 30],
+ currentRect: [0, 50, 30, 30]
+ },
+ {
+ node: $("#f"),
+ previousRect: [150, 0, 30, 40],
+ currentRect: [150, 50, 30, 40]
+ },
+ {
+ node: $("#c"),
+ previousRect: [50, 0, 10, 50],
+ currentRect: [50, 50, 10, 50]
+ },
+ {
+ node: $("#d"),
+ previousRect: [60, 0, 50, 10],
+ currentRect: [60, 50, 50, 10]
+ },
+ {
+ node: $("#e"),
+ previousRect: [110, 0, 40, 30],
+ currentRect: [110, 50, 40, 30]
+ }
+ ]});
+}, "Source attribution prioritizes by impact.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/sources.html b/testing/web-platform/tests/layout-instability/sources.html
new file mode 100644
index 0000000000..5bf3abfcc8
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/sources.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<title>Layout Instability: sources attribute</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+body { margin: 10px; }
+#shifter { position: relative; width: 300px; height: 100px; background: blue; }
+
+</style>
+<div id="shifter"></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+strrect = r => `[${r.x},${r.y},${r.width},${r.height}]`;
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+ const shifter = document.querySelector("#shifter");
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ shifter.style = "top: 60px; left: 10px";
+ await watcher.promise;
+
+ const sources = watcher.lastEntry.sources;
+ assert_equals(sources.length, 1);
+
+ const source = sources[0];
+ assert_equals(source.node, shifter);
+ assert_equals(strrect(source.previousRect), "[10,10,300,100]");
+ assert_equals(strrect(source.currentRect), "[20,70,300,100]");
+}, "Sources attribute.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/sticky-descendant-move.html b/testing/web-platform/tests/layout-instability/sticky-descendant-move.html
new file mode 100644
index 0000000000..afab89592d
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/sticky-descendant-move.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<title>Layout Instability: movement of descendant of sticky positioned</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div style="position: sticky; width: 400px; height: 300px; top: 0">
+ <div id="child" style="position: relative; width: 300px; height: 200px; background: yellow"></div>
+</div>
+<script>
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ const child = document.querySelector("#child");
+ child.style.top = '100px';
+
+ const expectedScore = computeExpectedScore(300 * (200 + 100), 100);
+
+ // Observer fires after the frame is painted.
+ assert_equals(watcher.score, 0);
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Movement of descendant of sticky positioned.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/sticky-layout-no-change.html b/testing/web-platform/tests/layout-instability/sticky-layout-no-change.html
new file mode 100644
index 0000000000..8f3b2cf373
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/sticky-layout-no-change.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<title>Layout Instability: sticky positioned layout no change</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<div style="height: 3000px"></div>
+<div id="sticky" style="position: sticky; width: 200px; height: 300px; bottom: 0">
+ <div style="will-change: transform; height: 3000px; background: yellow"></div>
+</div>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // This doesn't change layout because the sticky element sticks to the bottom.
+ sticky.style.marginTop = "-1000px";
+
+ await waitForAnimationFrames(3);
+ assert_equals(watcher.score, 0);
+}, 'Sticky layout no change.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/sub-frame.html b/testing/web-platform/tests/layout-instability/sub-frame.html
new file mode 100644
index 0000000000..d7cb40002e
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/sub-frame.html
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<html>
+<body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/test-adapter.js"></script>
+<script src="resources/util.js"></script>
+<style>
+ #j {
+ position: relative;
+ width: 300px;
+ height: 100px;
+ background-color: purple;
+ }
+</style>
+<div id="j"></div>
+<script>
+function shiftFrame() {
+ document.getElementById('j').style.top = '60px';
+}
+function unshiftFrame() {
+ document.getElementById('j').style.top = '';
+}
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+ shiftFrame();
+
+ const expectedScore = computeExpectedScore(300 * (100 + 60), 60);
+
+ cls_expect(watcher, {score: 0});
+ await watcher.promise;
+ cls_expect(watcher, {score: expectedScore});
+
+ unshiftFrame();
+ await watcher.promise;
+ cls_expect(watcher, {score: expectedScore * 2});
+
+ window.parent.postMessage({
+ type: 'layout shift score',
+ score: watcher.score,
+ expectedScore: expectedScore * 2,
+ }, '*');
+}, 'We will see two layout shift with the same score in the subframe.');
+</script>
+</body>
+</html> \ No newline at end of file
diff --git a/testing/web-platform/tests/layout-instability/supported-layout-type.html b/testing/web-platform/tests/layout-instability/supported-layout-type.html
new file mode 100644
index 0000000000..3ba209f50a
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/supported-layout-type.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<head>
+<title>PerformanceObserver.supportedEntryTypes contains "layout-shift"</title>
+</head>
+<body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script>
+test(() => {
+ assert_implements(window.LayoutShift, 'Layout Instability is not supported.');
+ assert_implements(typeof PerformanceObserver.supportedEntryTypes !== "undefined",
+ 'supportedEntryTypes is not supported.');
+ assert_greater_than(PerformanceObserver.supportedEntryTypes.indexOf("layout-shift"), -1,
+ "There should be an entry 'layout-shift' in PerformanceObserver.supportedEntryTypes");
+}, "supportedEntryTypes contains 'layoutShift'.");
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/layout-instability/toJSON.html b/testing/web-platform/tests/layout-instability/toJSON.html
new file mode 100644
index 0000000000..83ee9c968e
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/toJSON.html
@@ -0,0 +1,48 @@
+<!DOCTYPE HTML>
+<meta charset=utf-8>
+<title>Layout Instability: toJSON</title>
+<body>
+<style>
+#myDiv { position: relative; width: 300px; height: 100px; background: blue; }
+</style>
+<div id='myDiv'></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+promise_test(async t => {
+ assert_implements(window.LayoutShift, 'Layout Instability is not supported.');
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ return new Promise(resolve => {
+ const observer = new PerformanceObserver(
+ t.step_func(entryList => {
+ const entry = entryList.getEntries()[0];
+ assert_equals(typeof(entry.toJSON), 'function');
+ const json = entry.toJSON();
+ assert_equals(typeof(json), 'object');
+ const keys = [
+ // PerformanceEntry
+ 'name',
+ 'entryType',
+ 'startTime',
+ 'duration',
+ // LayoutShift
+ 'value',
+ 'hadRecentInput',
+ 'lastInputTime',
+ ];
+ for (const key of keys) {
+ assert_equals(json[key], entry[key],
+ `LayoutShift ${key} entry does not match its toJSON value`);
+ }
+ resolve();
+ })
+ );
+ observer.observe({type: 'layout-shift'});
+ document.getElementById('myDiv').style = "top: 60px";
+ });
+}, 'Test toJSON() in LayoutShift.');
+</script>
+</body>
diff --git a/testing/web-platform/tests/layout-instability/transform-above-filter-dynamic.html b/testing/web-platform/tests/layout-instability/transform-above-filter-dynamic.html
new file mode 100644
index 0000000000..1d7ed51a91
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/transform-above-filter-dynamic.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<title>Layout Instability: addition of scale transform above filter</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<div id=target style="width: 581px">
+ <div id=moved style="filter: saturate(1.1); width: 300px; height:300px; background: lightblue">
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ target.style.transform = "scale(1.1)";
+ await waitForAnimationFrames(1);
+
+ assert_equals(watcher.score, 0);
+}, 'addition of scale transform above filter');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/transform-above-perspective-dynamic.html b/testing/web-platform/tests/layout-instability/transform-above-perspective-dynamic.html
new file mode 100644
index 0000000000..fea65ca56a
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/transform-above-perspective-dynamic.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<title>Layout Instability: addition of transform above perspective</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<div id=target2>
+ <div id=perspective style="perspective: 1000px;">
+ <div id=target>Test</div>
+ </div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ target2.style.transform = 'translateX(0px)';
+ await waitForAnimationFrames(1);
+
+ assert_equals(watcher.score, 0);
+}, 'addition of transform above perspective');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/transform-change.html b/testing/web-platform/tests/layout-instability/transform-change.html
new file mode 100644
index 0000000000..ea1f10ac8a
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/transform-change.html
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<title>Layout Instability: no layout shift for transform change</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+body { margin: 0; }
+#transformed { position: relative; transform: translateX(20px); width: 100px; height: 100px; background: blue; }
+#child { width: 400px; height: 400px; }
+</style>
+<div id="transformed">
+ <div id="child"></div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the transform, for which no shift should be reported.
+ document.querySelector("#transformed").style = "transform: translateY(100px)";
+ // Change size of child, for which no shift should be reported, either.
+ document.querySelector("#child").style = "width: 300px";
+
+ await waitForAnimationFrames(2);
+ // No shift should be reported.
+ assert_equals(watcher.score, 0);
+}, 'no layout shift for transform change');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/transform-counter-layout-shift.html b/testing/web-platform/tests/layout-instability/transform-counter-layout-shift.html
new file mode 100644
index 0000000000..476e25a532
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/transform-counter-layout-shift.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<title>Layout Instability: no layout shift if transform change counters location change</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+body { margin: 0; }
+#transformed { position: relative; transform: translateX(20px); width: 100px; height: 100px; background: blue; }
+#child { width: 400px; height: 400px; }
+</style>
+<div id="transformed">
+ <div id="child"></div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the transform and the location at the same time, and the values
+ // cancel each other visually, for which no shift should be reported.
+ transformed.style.transform = 'translateY(100px)';
+ transformed.style.top = '-100px';
+ transformed.style.left = '20px';
+ // Change size of child, for which no shift should be reported, either.
+ child.style.width = '300px';
+
+ await waitForAnimationFrames(2);
+ // No shift should be reported.
+ assert_equals(watcher.score, 0);
+}, 'no layout shift if transform change counters location change');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/transform.html b/testing/web-platform/tests/layout-instability/transform.html
new file mode 100644
index 0000000000..98f94f53cc
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/transform.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<title>Layout Instability: shift inside a transformed container</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+
+body { margin: 0; }
+#container { transform: translateX(-300px) translateY(-40px); }
+#shifter { position: relative; width: 600px; height: 140px; background: blue; }
+
+</style>
+<div id="container">
+ <div id="shifter"></div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the position of the div.
+ document.querySelector("#shifter").style = "top: 60px";
+
+ // The shifter has size 600 x 140, but the container's transform
+ // reduces its viewport overlap.
+ const expectedScore = computeExpectedScore(
+ (600 - 300) * (140 - 40 + 60), 60);
+
+ await watcher.promise;
+ assert_equals(watcher.score, expectedScore);
+}, 'Transformed container.');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/translate-change.html b/testing/web-platform/tests/layout-instability/translate-change.html
new file mode 100644
index 0000000000..ddfc041700
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/translate-change.html
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<title>Layout Instability: no layout shift for change of individual transform property</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+body { margin: 0; }
+#transformed { position: relative; translate: 20px 0; width: 100px; height: 100px; background: blue; }
+#child { width: 400px; height: 400px; }
+</style>
+<div id="transformed">
+ <div id="child"></div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the transform, for which no shift should be reported.
+ document.querySelector("#transformed").style = "translate: 0 100px";
+ // Change size of child, for which no shift should be reported, either.
+ document.querySelector("#child").style = "width: 300px";
+
+ await waitForAnimationFrames(2);
+ // No shift should be reported.
+ assert_equals(watcher.score, 0);
+}, 'no layout shift for transform change');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/translate-counter-layout-shift.html b/testing/web-platform/tests/layout-instability/translate-counter-layout-shift.html
new file mode 100644
index 0000000000..18e03ad7f2
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/translate-counter-layout-shift.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<title>Layout Instability: no layout shift if translate change counters location change</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<style>
+body { margin: 0; }
+#transformed { position: relative; translate: 20px 0; width: 100px; height: 100px; background: blue; }
+#child { width: 400px; height: 400px; }
+</style>
+<div id="transformed">
+ <div id="child"></div>
+</div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Modify the transform and the location at the same time, and the values
+ // cancel each other visually, for which no shift should be reported.
+ transformed.style.translate = '0 100px';
+ transformed.style.top = '-100px';
+ transformed.style.left = '20px';
+ // Change size of child, for which no shift should be reported, either.
+ child.style.width = '300px';
+
+ await waitForAnimationFrames(2);
+ // No shift should be reported.
+ assert_equals(watcher.score, 0);
+}, 'no layout shift if translate change counters location change');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/video.html b/testing/web-platform/tests/layout-instability/video.html
new file mode 100644
index 0000000000..d699ba0ae3
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/video.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<title>Layout Instability: no shifts from advancing video track</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/test-adapter.js"></script>
+<script src="resources/util.js"></script>
+<video controls>
+ <source src="/media/white.webm" type="video/webm">
+</video>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+ var video = document.querySelector("video");
+
+ await new Promise(resolve => { video.oncanplay = resolve; });
+ await waitForAnimationFrames(2);
+
+ // TODO(crbug.com/1088311): There are still some shifts from creating the
+ // <video>, so the score is already > 0 here. For now, just verify that
+ // advancing the track does not increase it further.
+ var currentScore = watcher.score;
+
+ video.currentTime = 5;
+
+ await waitForAnimationFrames(3);
+ cls_expect(watcher, {score: currentScore});
+
+}, "No shifts from advancing video track.");
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/visibility-hidden-layout-and-visible.html b/testing/web-platform/tests/layout-instability/visibility-hidden-layout-and-visible.html
new file mode 100644
index 0000000000..35ee0d9ed8
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/visibility-hidden-layout-and-visible.html
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<title>Layout Instability: visibility:hidden change with layout</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<div id="target" style="position: absolute; top: 0; width: 200px; height: 200px; visibility: hidden; background: blue"></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Shift target, for which no shift should be reported because it's hidden.
+ target.style.top = '200px';
+ target.style.visibility = 'visible';
+
+ await waitForAnimationFrames(2);
+ // No shift should be reported.
+ assert_equals(watcher.score, 0);
+
+ // Shift again, for which shift should be reported.
+ target.style.top = '300px';
+
+ await watcher.promise;
+ const expectedScore = computeExpectedScore(200 * (200 + 100), 100);
+ assert_equals(watcher.score, expectedScore);
+
+}, 'visibility:hidden change with layout');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/visibility-hidden.html b/testing/web-platform/tests/layout-instability/visibility-hidden.html
new file mode 100644
index 0000000000..583be10cd2
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/visibility-hidden.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<title>Layout Instability: visibility:hidden</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<div id="target" style="position: absolute; top: 0; width: 400px; height: 400px; visibility: hidden; background: blue"></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Shift target, for which no shift should be reported because it's hidden.
+ document.querySelector("#target").style.top = '200px';
+
+ await waitForAnimationFrames(2);
+ // No shift should be reported.
+ assert_equals(watcher.score, 0);
+}, 'visibility:hidden');
+
+</script>
diff --git a/testing/web-platform/tests/layout-instability/visible-to-hidden.html b/testing/web-platform/tests/layout-instability/visible-to-hidden.html
new file mode 100644
index 0000000000..d6ac75a144
--- /dev/null
+++ b/testing/web-platform/tests/layout-instability/visible-to-hidden.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<title>Layout Instability: visibility:hidden</title>
+<link rel="help" href="https://wicg.github.io/layout-instability/" />
+<div id="target" style="position: absolute; top: 0; width: 400px; height: 400px; background: blue;"></div>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/util.js"></script>
+<script>
+
+promise_test(async () => {
+ const watcher = new ScoreWatcher;
+
+ // Wait for the initial render to complete.
+ await waitForAnimationFrames(2);
+
+ // Shift target and make hidden at the same time. Should not be reported!
+ document.querySelector("#target").style.top = '200px';
+ document.querySelector("#target").style.visibility = 'hidden';
+
+ await waitForAnimationFrames(2);
+ // No shift should be reported.
+ assert_equals(watcher.score, 0);
+}, 'visible to hidden');
+
+</script>