From 3042aa5997674e05a71ff2f16e69441da82beba2 Mon Sep 17 00:00:00 2001 From: asakusuma Date: Sat, 5 Jan 2019 21:46:15 -0800 Subject: [PATCH] Handle zero-area and display: none cases Attempts to satsify the spec: https://www.w3.org/TR/intersection-observer/#update-intersection-observations-algo isIntersecting, non-zero area, and display:nonen are all related, so fixing in one swoop. Fixes: https://github.com/linkedin/spaniel/issues/93 https://github.com/linkedin/spaniel/issues/73 Related issues: https://github.com/w3c/IntersectionObserver/issues/69 https://github.com/w3c/IntersectionObserver/issues/222 --- src/index.ts | 6 +- src/interfaces.ts | 2 +- src/intersection-observer.ts | 44 +- src/native-spaniel-observer.ts | 7 +- src/spaniel-observer.ts | 7 +- src/utils.ts | 17 +- test/headless/context.js | 24 +- test/headless/run.js | 1 + test/headless/specs/intersection-observer.js | 613 ++++++++++-------- test/headless/specs/watcher/general.spec.js | 285 ++++---- .../specs/watcher/impression-event.spec.js | 196 +++--- 11 files changed, 669 insertions(+), 533 deletions(-) diff --git a/src/index.ts b/src/index.ts index ff5de30..58b7edf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,8 +11,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. import { SpanielIntersectionObserver, generateEntry } from './intersection-observer'; -import { entrySatisfiesRatio } from './utils'; - import { SpanielTrackedElement, DOMMargin, IntersectionObserverClass } from './interfaces'; export { Watcher, WatcherConfig } from './watcher'; @@ -46,13 +44,13 @@ export function queryElement(el: Element, callback: (bcr: ClientRect, frame: Fra } export function elementSatisfiesRatio( - el: Element, + el: HTMLElement, ratio: number = 0, callback: (result: Boolean) => void, rootMargin: DOMMargin = { top: 0, bottom: 0, left: 0, right: 0 } ) { queryElement(el, (bcr: ClientRect, frame: Frame) => { let entry = generateEntry(frame, bcr, el, rootMargin); - callback(entrySatisfiesRatio(entry, ratio)); + callback(entry.isIntersecting && entry.intersectionRatio >= ratio); }); } diff --git a/src/interfaces.ts b/src/interfaces.ts index e6d94c3..a389a9e 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -9,7 +9,7 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. */ -export interface SpanielTrackedElement extends Element { +export interface SpanielTrackedElement extends HTMLElement { __spanielId: string; } diff --git a/src/intersection-observer.ts b/src/intersection-observer.ts index 5c20235..d005085 100644 --- a/src/intersection-observer.ts +++ b/src/intersection-observer.ts @@ -9,7 +9,7 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. */ -import { entrySatisfiesRatio } from './utils'; +import { calculateIsIntersecting } from './utils'; import { Frame, ElementScheduler, generateToken } from './metal/index'; @@ -63,7 +63,7 @@ export class SpanielIntersectionObserver implements IntersectionObserver { public thresholds: number[]; private records: { [index: string]: EntryEvent }; - observe(target: Element) { + observe(target: HTMLElement) { let trackedTarget = target as SpanielTrackedElement; let id = (trackedTarget.__spanielId = trackedTarget.__spanielId || generateToken()); @@ -77,7 +77,7 @@ export class SpanielIntersectionObserver implements IntersectionObserver { ); return id; } - private onTick(frame: Frame, id: string, bcr: DOMRectReadOnly, el: Element) { + private onTick(frame: Frame, id: string, bcr: DOMRectReadOnly, el: SpanielTrackedElement) { let { numSatisfiedThresholds, entry } = this.generateEntryEvent(frame, bcr, el); let record: EntryEvent = this.records[id] || @@ -86,8 +86,12 @@ export class SpanielIntersectionObserver implements IntersectionObserver { numSatisfiedThresholds: 0 }); - if (numSatisfiedThresholds !== record.numSatisfiedThresholds) { + if ( + numSatisfiedThresholds !== record.numSatisfiedThresholds || + entry.isIntersecting !== record.entry.isIntersecting + ) { record.numSatisfiedThresholds = numSatisfiedThresholds; + record.entry = entry; this.scheduler.scheduleWork(() => { this.callback([entry]); }); @@ -104,13 +108,13 @@ export class SpanielIntersectionObserver implements IntersectionObserver { takeRecords(): IntersectionObserverEntry[] { return []; } - private generateEntryEvent(frame: Frame, bcr: DOMRectReadOnly, el: Element): EntryEvent { + private generateEntryEvent(frame: Frame, bcr: DOMRectReadOnly, el: HTMLElement): EntryEvent { let count: number = 0; let entry = generateEntry(frame, bcr, el, this.rootMarginObj); for (let i = 0; i < this.thresholds.length; i++) { let threshold = this.thresholds[i]; - if (entrySatisfiesRatio(entry, threshold)) { + if (entry.intersectionRatio >= threshold) { count++; } } @@ -149,7 +153,7 @@ function addRatio(entryInit: SpanielIntersectionObserverEntryInit): Intersection intersectionRect, target, intersectionRatio, - isIntersecting: null + isIntersecting: calculateIsIntersecting({ intersectionRect }) }; } @@ -179,12 +183,36 @@ export class IntersectionObserverEntry implements IntersectionObserverEntryInit }; */ +function emptyRect(): ClientRect | DOMRect { + return { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0 + }; +} + export function generateEntry( frame: Frame, bcr: DOMRectReadOnly, - el: Element, + el: HTMLElement, rootMargin: DOMMargin ): IntersectionObserverEntry { + if (el.style.display === 'none') { + return { + boundingClientRect: emptyRect(), + intersectionRatio: 0, + intersectionRect: emptyRect(), + isIntersecting: false, + rootBounds: emptyRect(), + target: el, + time: frame.timestamp + }; + } let { bottom, right } = bcr; let rootBounds: ClientRect = { left: frame.x - rootMargin.left, diff --git a/src/native-spaniel-observer.ts b/src/native-spaniel-observer.ts index 28927b1..948690a 100644 --- a/src/native-spaniel-observer.ts +++ b/src/native-spaniel-observer.ts @@ -9,7 +9,7 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. */ -import { entrySatisfiesRatio } from './utils'; +import { calculateIsIntersecting } from './utils'; import { IntersectionObserverInit, @@ -187,9 +187,10 @@ export class SpanielObserver implements SpanielObserverInterface { let hasTimeThreshold = !!state.threshold.time; let spanielEntry: SpanielObserverEntry = this.generateSpanielEntry(entry, state); - const ratioSatisfied = entrySatisfiesRatio(entry, state.threshold.ratio); + const ratioSatisfied = entry.intersectionRatio >= state.threshold.ratio; + const isIntersecting = calculateIsIntersecting(entry); - if (ratioSatisfied && !state.lastSatisfied) { + if (ratioSatisfied && !state.lastSatisfied && isIntersecting) { spanielEntry.entering = true; if (hasTimeThreshold) { state.lastVisible = time; diff --git a/src/spaniel-observer.ts b/src/spaniel-observer.ts index abeff81..30b853d 100644 --- a/src/spaniel-observer.ts +++ b/src/spaniel-observer.ts @@ -9,7 +9,7 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. */ -import { entrySatisfiesRatio } from './utils'; +import { calculateIsIntersecting } from './utils'; import { SpanielIntersectionObserver } from './intersection-observer'; @@ -192,9 +192,10 @@ export class SpanielObserver implements SpanielObserverInterface { let hasTimeThreshold = !!state.threshold.time; let spanielEntry: SpanielObserverEntry = this.generateSpanielEntry(entry, state); - const ratioSatisfied = entrySatisfiesRatio(entry, state.threshold.ratio); + const ratioSatisfied = entry.intersectionRatio >= state.threshold.ratio; + const isIntersecting = calculateIsIntersecting(entry); - if (ratioSatisfied && !state.lastSatisfied) { + if (ratioSatisfied && !state.lastSatisfied && isIntersecting) { spanielEntry.entering = true; if (hasTimeThreshold) { state.lastVisible = time; diff --git a/src/utils.ts b/src/utils.ts index 3a6f05e..a5221e5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,16 +1,3 @@ -export function entrySatisfiesRatio(entry: IntersectionObserverEntry, threshold: number) { - let { boundingClientRect, intersectionRatio } = entry; - - // Edge case where item has no actual area - if (boundingClientRect.width === 0 || boundingClientRect.height === 0) { - let { boundingClientRect, intersectionRect } = entry; - return ( - boundingClientRect.left === intersectionRect.left && - boundingClientRect.top === intersectionRect.top && - intersectionRect.width >= 0 && - intersectionRect.height >= 0 - ); - } else { - return intersectionRatio > threshold || (intersectionRatio === 1 && threshold === 1); - } +export function calculateIsIntersecting({ intersectionRect }: { intersectionRect: ClientRect }) { + return intersectionRect.width > 0 || intersectionRect.height > 0; } diff --git a/test/headless/context.js b/test/headless/context.js index 322f8e0..755f4b3 100644 --- a/test/headless/context.js +++ b/test/headless/context.js @@ -1,6 +1,6 @@ /* Copyright 2017 LinkedIn Corp. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
 You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - + Unless required by applicable law or agreed to in writing, software 
distributed under the License is distributed on an "AS IS" BASIS, 
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. */ @@ -13,19 +13,21 @@ const MAC_WINDOW_BAR_HEIGHT = 22; // See https://github.com/segmentio/nightmare/ export default class Context { constructor() { - this._nightmare = Nightmare({ show: false }), - this._nightmare.viewport(400, 400 + MAC_WINDOW_BAR_HEIGHT); + (this._nightmare = Nightmare({ show: false })), this._nightmare.viewport(400, 400 + MAC_WINDOW_BAR_HEIGHT); this._events = []; this._results = []; this._assertions = []; - this._execution = this._root = this._nightmare.goto('http://localhost:3000/').wait(10).evaluate(function() { - window.STATE = {}; - window.createDiv = function(id) { - var div = document.createElement('div'); - div.id = id; - document.body.appendChild(div); - } - }); + this._execution = this._root = this._nightmare + .goto('http://localhost:3000/') + .wait(10) + .evaluate(function() { + window.STATE = {}; + window.createDiv = function(id) { + var div = document.createElement('div'); + div.id = id; + document.body.appendChild(div); + }; + }); } close() { return this._root.end(); diff --git a/test/headless/run.js b/test/headless/run.js index 6f1056d..006b2f9 100644 --- a/test/headless/run.js +++ b/test/headless/run.js @@ -16,6 +16,7 @@ server.stdout.on('data', data => { '--require', '@babel/register', 'test/headless/specs/**/*.js', + 'test/headless/specs/*.js', '--exit', '--timeout', '5000' diff --git a/test/headless/specs/intersection-observer.js b/test/headless/specs/intersection-observer.js index 5a2643d..9b53ba9 100644 --- a/test/headless/specs/intersection-observer.js +++ b/test/headless/specs/intersection-observer.js @@ -5,273 +5,374 @@ Unless required by applicable law or agreed to in writing, software
distribute */ import { assert } from 'chai'; -import { - default as testModule, - TestClass -} from './../test-module'; +import { default as testModule, TestClass } from './../test-module'; import constants from './../../constants.js'; -const { time: { IMPRESSION_THRESHOLD } } = constants; +const { + time: { IMPRESSION_THRESHOLD } +} = constants; -testModule('IntersectionObserver', class extends TestClass { - ['@test observing a visible element should fire callback immediately']() { - return this.context.evaluate(() => { - window.STATE.impressions = 0; - let target = document.querySelector('.tracked-item[data-id="1"]'); - let observer = new spaniel.IntersectionObserver(function() { - createDiv('impression-div'); - window.STATE.impressions++; - }); - observer.observe(target); - }) - .waitForImpression() - .getExecution() - .evaluate(function() { - return window.STATE.impressions; - }).then(function(result) { - assert.equal(result, 1, 'Callback fired once'); - }); - } +testModule( + 'IntersectionObserver', + class extends TestClass { + ['@test observing a visible element should fire callback immediately']() { + return this.context + .evaluate(() => { + window.STATE.impressions = 0; + let target = document.querySelector('.tracked-item[data-id="1"]'); + let observer = new spaniel.IntersectionObserver(function(entries) { + createDiv('impression-div'); + if (entries[0].isIntersecting) { + window.STATE.impressions++; + } + }); + observer.observe(target); + }) + .waitForImpression() + .getExecution() + .evaluate(function() { + return window.STATE.impressions; + }) + .then(function(result) { + assert.equal(result, 1, 'Callback fired once'); + }); + } - ['@test observing a visible element with a single threshold should fire callback immediately']() { - return this.context.evaluate(function() { - window.STATE.impressions = 0; - let target = document.querySelector('.tracked-item[data-id="1"]'); - let observer = new spaniel.IntersectionObserver(function() { - createDiv('impression-div'); - window.STATE.impressions++; - }, { - threshold: 0.9 - }); - observer.observe(target); - }) - .waitForImpression() - .getExecution() - .evaluate(function() { - return window.STATE.impressions; - }).then(function(result) { - assert.equal(result, 1, 'Callback fired once'); - }); - } + ['@test observing a hidden element should fire an event with a ratio of 0']() { + return this.context + .evaluate(() => { + window.STATE.intersectionEvents = 0; + window.STATE.impressions = 0; + let target = (window.testTarget = document.querySelector('.tracked-item[data-id="1"]')); + target.style.display = 'none'; + let observer = new spaniel.IntersectionObserver(function(entries) { + createDiv('impression-div'); + window.STATE.intersectionEvents++; - ['@test observing a non visible element should not fire']() { - return this.context.evaluate(function() { - window.STATE.impressions = 0; - let target = document.querySelector('.tracked-item[data-id="5"]'); - let observer = new spaniel.IntersectionObserver(function() { - window.STATE.impressions++; - }, { - threshold: 0.75 - }); - observer.observe(target); - }) - .scrollTo(74) - .wait(50) - .getExecution() - .evaluate(function() { - return window.STATE.impressions; - }).then(function(result) { - assert.equal(result, 0, 'Callback fired zero times'); - }); - } + if (entries[0].intersectionRatio > 0) { + window.STATE.impressions++; + } + }); + observer.observe(target); + }) + .wait(IMPRESSION_THRESHOLD) + .getExecution() + .evaluate(function() { + return window.STATE; + }) + .then(function({ impressions, intersectionEvents }) { + assert.equal(impressions, 0, 'No visible events'); + assert.equal(intersectionEvents, 1, 'Callback fired once'); + }); + } - ['@test observing a non visible element and then scrolling just past threshold should fire once']() { - return this.context.evaluate(function() { - window.STATE.impressions = 0; - let target = document.querySelector('.tracked-item[data-id="5"]'); - let observer = new spaniel.IntersectionObserver(function() { - createDiv('impression-div'); - window.STATE.impressions++; - }, { - threshold: 0.75 - }); - observer.observe(target); - }) - .wait(IMPRESSION_THRESHOLD) - .scrollTo(80) - .waitForImpression() - .getExecution() - .evaluate(function() { - return window.STATE.impressions; - }).then(function(result) { - assert.equal(result, 1, 'Callback fired once'); - }); - } + ['@test hiding an observed element should fire an event with isIntersecting']() { + return this.context + .evaluate(() => { + window.STATE.intersectionEvents = 0; + window.STATE.impressions = 0; + let target = (window.testTarget = document.querySelector('.tracked-item[data-id="1"]')); + let observer = new spaniel.IntersectionObserver(function(entries) { + createDiv('impression-div'); + window.STATE.intersectionEvents++; - ['@test observing a non visible element and then scrolling just past threshold and then back out should fire twice']() { - return this.context.evaluate(function() { - window.STATE.impressions = 0; - let target = document.querySelector('.tracked-item[data-id="5"]'); - let observer = new spaniel.IntersectionObserver(function() { - window.STATE.impressions++; - createDiv('impression-div-' + window.STATE.impressions); - }, { - threshold: 0.75 - }); - observer.observe(target); - }) - .wait(IMPRESSION_THRESHOLD) - .scrollTo(80) - .waitForImpression(1) - .scrollTo(70) - .waitForImpression(2) - .getExecution() - .evaluate(function() { - return window.STATE.impressions; - }).then(function(result) { - assert.equal(result, 2, 'Callback fired twice'); - }); - } + if (entries[0].isIntersecting) { + window.STATE.impressions++; + } + }); + observer.observe(target); + }) + .wait(IMPRESSION_THRESHOLD) + .evaluate(function() { + window.testTarget.style.display = 'none'; + }) + .wait(IMPRESSION_THRESHOLD) + .getExecution() + .evaluate(function() { + return window.STATE; + }) + .then(function({ impressions, intersectionEvents }) { + assert.equal(intersectionEvents, 2, 'Callback fired twice'); + assert.equal(impressions, 1, 'One visible event'); + }); + } - ['@test setting rootMargin and then scrolling just past threshold and then back out should fire twice']() { - return this.context.evaluate(function() { - window.STATE.impressions = 0; - let target = document.querySelector('.tracked-item[data-id="5"]'); - let observer = new spaniel.IntersectionObserver(function() { - window.STATE.impressions++; - createDiv('impression-div-' + window.STATE.impressions); - }, { - threshold: 0.75, - rootMargin: '-25px 0px' - }); - observer.observe(target); - }) - .wait(IMPRESSION_THRESHOLD) - .scrollTo(105) - .waitForImpression(1) - .scrollTo(95) - .waitForImpression(2) - .getExecution() - .evaluate(function() { - return window.STATE.impressions; - }).then(function(result) { - assert.equal(result, 2, 'Callback fired twice'); - }); - } + ['@test observing a visible element with a single threshold should fire callback immediately']() { + return this.context + .evaluate(function() { + window.STATE.impressions = 0; + let target = document.querySelector('.tracked-item[data-id="1"]'); + let observer = new spaniel.IntersectionObserver( + function() { + createDiv('impression-div'); + window.STATE.impressions++; + }, + { + threshold: 0.9 + } + ); + observer.observe(target); + }) + .waitForImpression() + .getExecution() + .evaluate(function() { + return window.STATE.impressions; + }) + .then(function(result) { + assert.equal(result, 1, 'Callback fired once'); + }); + } - ['@test unobserve should work with single element']() { - return this.context.evaluate(function() { - window.STATE.impressions = 0; - window.target = document.querySelector('.tracked-item[data-id="1"]'); - window.observer = new spaniel.IntersectionObserver(function() { - window.STATE.impressions++; - createDiv('impression-div'); - }); - window.observer.observe(window.target); - }) - .waitForImpression() - .evaluate(function() { - window.observer.unobserve(window.target); - }) - .wait(50) - .scrollTo(500) - .wait(50) - .scrollTo(0) - .wait(50) - .getExecution() - .evaluate(function() { - return window.STATE.impressions; - }).then(function(result) { - assert.equal(result, 1, 'Callback fired only once'); - }); - } + ['@test observing a non visible element should not fire']() { + return this.context + .evaluate(function() { + window.STATE.impressions = 0; + let target = document.querySelector('.tracked-item[data-id="5"]'); + let observer = new spaniel.IntersectionObserver( + function() { + window.STATE.impressions++; + }, + { + threshold: 0.75 + } + ); + observer.observe(target); + }) + .scrollTo(74) + .wait(50) + .getExecution() + .evaluate(function() { + return window.STATE.impressions; + }) + .then(function(result) { + assert.equal(result, 0, 'Callback fired zero times'); + }); + } - ['@test disconnect should work with multiple elements']() { - return this.context.evaluate(function() { - window.STATE.impressions = 0; - target1 = document.querySelector('.tracked-item[data-id="1"]'); - target2 = document.querySelector('.tracked-item[data-id="2"]'); - target3 = document.querySelector('.tracked-item[data-id="3"]'); - window.observer = new spaniel.IntersectionObserver(function(event) { - window.STATE.impressions+= event.length; - createDiv('impression-div-' + window.STATE.impressions); - }); - window.observer.observe(target1); - window.observer.observe(target2); - window.observer.observe(target3); - }) - .waitForImpression(1) - .evaluate(function() { - window.observer.disconnect(); - }) - .wait(50) - .scrollTo(500) - .wait(50) - .scrollTo(0) - .wait(50) - .getExecution() - .evaluate(function() { - return window.STATE.impressions; - }).then(function(result) { - assert.equal(result, 3, 'Callback fired 3 times'); - }); - } + ['@test observing a non visible element and then scrolling just past threshold should fire once']() { + return this.context + .evaluate(function() { + window.STATE.impressions = 0; + let target = document.querySelector('.tracked-item[data-id="5"]'); + let observer = new spaniel.IntersectionObserver( + function() { + createDiv('impression-div'); + window.STATE.impressions++; + }, + { + threshold: 0.75 + } + ); + observer.observe(target); + }) + .wait(IMPRESSION_THRESHOLD) + .scrollTo(80) + .waitForImpression() + .getExecution() + .evaluate(function() { + return window.STATE.impressions; + }) + .then(function(result) { + assert.equal(result, 1, 'Callback fired once'); + }); + } - ['@test can restart observing after disconnect']() { - return this.context.evaluate(function() { - window.STATE.impressions = 0; - target1 = document.querySelector('.tracked-item[data-id="1"]'); - target2 = document.querySelector('.tracked-item[data-id="2"]'); - target3 = document.querySelector('.tracked-item[data-id="3"]'); - window.observer = new spaniel.IntersectionObserver(function(event) { - window.STATE.impressions+= event.length; - createDiv('impression-div-' + window.STATE.impressions); - }); - window.observer.observe(target1); - window.observer.observe(target2); - window.observer.observe(target3); - }) - .waitForImpression(1) - .evaluate(function() { - window.observer.disconnect(); - }) - .wait(100) - .evaluate(function() { - window.observer.observe(document.querySelector('.tracked-item[data-id="1"]')); - }) - .waitForImpression(4) - .scrollTo(500) - .wait(50) - .scrollTo(0) - .waitForImpression(6) - .getExecution() - .evaluate(function() { - return window.STATE.impressions; - }).then(function(result) { - assert.equal(result, 6, 'Callback fired 6 times'); - }); - } + ['@test observing a non visible element and then scrolling just past threshold and then back out should fire twice']() { + return this.context + .evaluate(function() { + window.STATE.impressions = 0; + let target = document.querySelector('.tracked-item[data-id="5"]'); + let observer = new spaniel.IntersectionObserver( + function() { + window.STATE.impressions++; + createDiv('impression-div-' + window.STATE.impressions); + }, + { + threshold: 0.75 + } + ); + observer.observe(target); + }) + .wait(IMPRESSION_THRESHOLD) + .scrollTo(80) + .waitForImpression(1) + .scrollTo(70) + .waitForImpression(2) + .getExecution() + .evaluate(function() { + return window.STATE.impressions; + }) + .then(function(result) { + assert.equal(result, 2, 'Callback fired twice'); + }); + } + + ['@test setting rootMargin and then scrolling just past threshold and then back out should fire twice']() { + return this.context + .evaluate(function() { + window.STATE.impressions = 0; + let target = document.querySelector('.tracked-item[data-id="5"]'); + let observer = new spaniel.IntersectionObserver( + function() { + window.STATE.impressions++; + createDiv('impression-div-' + window.STATE.impressions); + }, + { + threshold: 0.75, + rootMargin: '-25px 0px' + } + ); + observer.observe(target); + }) + .wait(IMPRESSION_THRESHOLD) + .scrollTo(105) + .waitForImpression(1) + .scrollTo(95) + .waitForImpression(2) + .getExecution() + .evaluate(function() { + return window.STATE.impressions; + }) + .then(function(result) { + assert.equal(result, 2, 'Callback fired twice'); + }); + } + + ['@test unobserve should work with single element']() { + return this.context + .evaluate(function() { + window.STATE.impressions = 0; + window.target = document.querySelector('.tracked-item[data-id="1"]'); + window.observer = new spaniel.IntersectionObserver(function() { + window.STATE.impressions++; + createDiv('impression-div'); + }); + window.observer.observe(window.target); + }) + .waitForImpression() + .evaluate(function() { + window.observer.unobserve(window.target); + }) + .wait(50) + .scrollTo(500) + .wait(50) + .scrollTo(0) + .wait(50) + .getExecution() + .evaluate(function() { + return window.STATE.impressions; + }) + .then(function(result) { + assert.equal(result, 1, 'Callback fired only once'); + }); + } + + ['@test disconnect should work with multiple elements']() { + return this.context + .evaluate(function() { + window.STATE.impressions = 0; + target1 = document.querySelector('.tracked-item[data-id="1"]'); + target2 = document.querySelector('.tracked-item[data-id="2"]'); + target3 = document.querySelector('.tracked-item[data-id="3"]'); + window.observer = new spaniel.IntersectionObserver(function(event) { + window.STATE.impressions += event.length; + createDiv('impression-div-' + window.STATE.impressions); + }); + window.observer.observe(target1); + window.observer.observe(target2); + window.observer.observe(target3); + }) + .waitForImpression(1) + .evaluate(function() { + window.observer.disconnect(); + }) + .wait(50) + .scrollTo(500) + .wait(50) + .scrollTo(0) + .wait(50) + .getExecution() + .evaluate(function() { + return window.STATE.impressions; + }) + .then(function(result) { + assert.equal(result, 3, 'Callback fired 3 times'); + }); + } + + ['@test can restart observing after disconnect']() { + return this.context + .evaluate(function() { + window.STATE.impressions = 0; + target1 = document.querySelector('.tracked-item[data-id="1"]'); + target2 = document.querySelector('.tracked-item[data-id="2"]'); + target3 = document.querySelector('.tracked-item[data-id="3"]'); + window.observer = new spaniel.IntersectionObserver(function(event) { + window.STATE.impressions += event.length; + createDiv('impression-div-' + window.STATE.impressions); + }); + window.observer.observe(target1); + window.observer.observe(target2); + window.observer.observe(target3); + }) + .waitForImpression(1) + .evaluate(function() { + window.observer.disconnect(); + }) + .wait(100) + .evaluate(function() { + window.observer.observe(document.querySelector('.tracked-item[data-id="1"]')); + }) + .waitForImpression(4) + .scrollTo(500) + .wait(50) + .scrollTo(0) + .waitForImpression(6) + .getExecution() + .evaluate(function() { + return window.STATE.impressions; + }) + .then(function(result) { + assert.equal(result, 6, 'Callback fired 6 times'); + }); + } - /* Root inlcusion test case */ - ['@test observing a non visible element within a root and then scrolling should fire callbacks']() { - return this.context.evaluate(function() { - window.STATE.impressions = 0; - let root = document.getElementById('root'); - let target = document.querySelector('.tracked-item-root[data-root-target-id="5"]'); - let observer = new spaniel.IntersectionObserver(function() { - window.STATE.impressions++; - createDiv('impression-div-' + window.STATE.impressions); - }, { - root: root, - threshold: 0.6 - }); - observer.observe(target); - }) - .wait(IMPRESSION_THRESHOLD) - .evaluate(function() { - root.scrollTop = 300; - }) - .waitForImpression(1) - .evaluate(function() { - root.scrollTop = 180; - }) - .waitForImpression(2) - .getExecution() - .evaluate(function() { - return window.STATE.impressions; - }).then(function(result) { - assert.equal(result, 2, 'Callback fired twice'); - }); + /* Root inlcusion test case */ + ['@test observing a non visible element within a root and then scrolling should fire callbacks']() { + return this.context + .evaluate(function() { + window.STATE.impressions = 0; + let root = document.getElementById('root'); + let target = document.querySelector('.tracked-item-root[data-root-target-id="5"]'); + let observer = new spaniel.IntersectionObserver( + function() { + window.STATE.impressions++; + createDiv('impression-div-' + window.STATE.impressions); + }, + { + root: root, + threshold: 0.6 + } + ); + observer.observe(target); + }) + .wait(IMPRESSION_THRESHOLD) + .evaluate(function() { + root.scrollTop = 300; + }) + .waitForImpression(1) + .evaluate(function() { + root.scrollTop = 180; + }) + .waitForImpression(2) + .getExecution() + .evaluate(function() { + return window.STATE.impressions; + }) + .then(function(result) { + assert.equal(result, 2, 'Callback fired twice'); + }); + } } -}); +); diff --git a/test/headless/specs/watcher/general.spec.js b/test/headless/specs/watcher/general.spec.js index e64cfbd..c8ce39a 100644 --- a/test/headless/specs/watcher/general.spec.js +++ b/test/headless/specs/watcher/general.spec.js @@ -1,149 +1,160 @@ /* Copyright 2017 LinkedIn Corp. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
 You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - + Unless required by applicable law or agreed to in writing, software 
distributed under the License is distributed on an "AS IS" BASIS, 
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. */ import { assert } from 'chai'; import sinon from 'sinon'; -import { - default as testModule, - TestClass -} from './../../test-module'; +import { default as testModule, TestClass } from './../../test-module'; import constants from './../../../constants.js'; -const { time: { RAF_THRESHOLD } } = constants; +const { + time: { RAF_THRESHOLD } +} = constants; -testModule('Watcher', class extends TestClass { - ['@test unwatch works']() { - return this.context.evaluate(() => { - window.STATE.exposed = 0; - window.STATE.exposedFirst = 0; - window.watcher = new spaniel.Watcher(); - window.target = document.querySelector('.tracked-item[data-id="6"]'); - - window.watcher.watch(window.target, function() { - window.STATE.exposed++; - createDiv('exposed-div-' + window.STATE.exposed); - }); - - var referenceElement = document.querySelector('.tracked-item[data-id="1"]'); - window.watcher.watch(referenceElement, function(e, meta) { - if (e == 'exposed') { - window.STATE.exposedFirst++; - createDiv('first-element-exposed-div-' + window.STATE.exposedFirst); - } - }); - }) - .onDOMReady() - .scrollTo(200) - .waitForExposed(1) - .scrollTo(0) - .waitForNthElemEvent('first', 'exposed', '1') - .evaluate(() => { - window.watcher.unwatch(window.target); - }) - .scrollTo(200) - .getExecution() - .evaluate(function() { - return window.STATE.exposed; - }).then(function(result) { - assert.equal(result, 1, 'Callback fired only once'); - }); - } - ['@test destroy works']() { - return this.context.evaluate(() => { - window.STATE.exposed = 0; - window.watcher = new spaniel.Watcher(); - window.target = document.querySelector('.tracked-item[data-id="6"]'); - window.watcher.watch(window.target, function() { - window.STATE.exposed++; - }); - }) - .wait(RAF_THRESHOLD * 5) - .scrollTo(200) - .wait(RAF_THRESHOLD * 5) - .scrollTo(0) - .wait(RAF_THRESHOLD * 5) - .evaluate(() => { - window.watcher.destroy(); - }) - .wait(RAF_THRESHOLD * 5) - .scrollTo(200) - .wait(RAF_THRESHOLD * 5) - .getExecution() - .evaluate(function() { - return window.STATE.exposed; - }).then(function(result) { - assert.equal(result, 1, 'Callback fired only once'); - }); - } - ['@test unwatching from one watcher does not unwatch others']() { - return this.context.evaluate(() => { - window.STATE.exposed = 0; - window.STATE.exposedFirst = 0; - window.watcher1 = new spaniel.Watcher(); - window.watcher2 = new spaniel.Watcher(); - window.target = document.querySelector('.tracked-item[data-id="6"]'); - window.watcher1.watch(window.target, function() { - window.STATE.exposed++; - createDiv('exposed-div-' + window.STATE.exposed); - }); - window.watcher2.watch(window.target, function() { - window.STATE.exposed++; - createDiv('exposed-div-' + window.STATE.exposed); - }); - - var referenceElement = document.querySelector('.tracked-item[data-id="1"]'); - window.watcher.watch(referenceElement, function(e, meta) { - if (e == 'exposed') { - window.STATE.exposedFirst++; - createDiv('first-element-exposed-div-' + window.STATE.exposedFirst); - } - }); - }) - .onDOMReady() - .scrollTo(200) - .waitForExposed(1) - .scrollTo(0) - .waitForNthElemEvent('first', 'exposed', '1') - .evaluate(() => { - window.watcher1.unwatch(window.target); - }) - .scrollTo(200) - .waitForExposed(3) - .getExecution() - .evaluate(function() { - return window.STATE.exposed; - }).then(function(result) { - assert.equal(result, 3, 'Callback fired 3 times'); - }); - } - ['@test watched item callbacks fire in order']() { - return this.context.evaluate(() => { - window.STATE.order = []; - window.watcher = new spaniel.Watcher(); - var t1 = document.querySelector('.tracked-item[data-id="1"]'); - var t2 = document.querySelector('.tracked-item[data-id="2"]'); - window.watcher.watch(t1, function() { - window.STATE.order.push(1); - createDiv('exposed-div-1'); - }); - window.watcher.watch(t2, function() { - createDiv('exposed-div-2'); - window.STATE.order.push(2); - }); - }) - .onDOMReady() - .waitForExposed(1) - .waitForExposed(2) - .getExecution() - .evaluate(function() { - return window.STATE.order; - }).then(function(result) { - assert.equal(result[0], 1, 'First watched item callback fires first'); - assert.equal(result[1], 2, 'Second watched item callback fires second'); - }); +testModule( + 'Watcher', + class extends TestClass { + ['@test unwatch works']() { + return this.context + .evaluate(() => { + window.STATE.exposed = 0; + window.STATE.exposedFirst = 0; + window.watcher = new spaniel.Watcher(); + window.target = document.querySelector('.tracked-item[data-id="6"]'); + + window.watcher.watch(window.target, function() { + window.STATE.exposed++; + createDiv('exposed-div-' + window.STATE.exposed); + }); + + var referenceElement = document.querySelector('.tracked-item[data-id="1"]'); + window.watcher.watch(referenceElement, function(e, meta) { + if (e == 'exposed') { + window.STATE.exposedFirst++; + createDiv('first-element-exposed-div-' + window.STATE.exposedFirst); + } + }); + }) + .onDOMReady() + .scrollTo(200) + .waitForExposed(1) + .scrollTo(0) + .waitForNthElemEvent('first', 'exposed', '1') + .evaluate(() => { + window.watcher.unwatch(window.target); + }) + .scrollTo(200) + .getExecution() + .evaluate(function() { + return window.STATE.exposed; + }) + .then(function(result) { + assert.equal(result, 1, 'Callback fired only once'); + }); + } + ['@test destroy works']() { + return this.context + .evaluate(() => { + window.STATE.exposed = 0; + window.watcher = new spaniel.Watcher(); + window.target = document.querySelector('.tracked-item[data-id="6"]'); + window.watcher.watch(window.target, function() { + window.STATE.exposed++; + }); + }) + .wait(RAF_THRESHOLD * 5) + .scrollTo(200) + .wait(RAF_THRESHOLD * 5) + .scrollTo(0) + .wait(RAF_THRESHOLD * 5) + .evaluate(() => { + window.watcher.destroy(); + }) + .wait(RAF_THRESHOLD * 5) + .scrollTo(200) + .wait(RAF_THRESHOLD * 5) + .getExecution() + .evaluate(function() { + return window.STATE.exposed; + }) + .then(function(result) { + assert.equal(result, 1, 'Callback fired only once'); + }); + } + ['@test unwatching from one watcher does not unwatch others']() { + return this.context + .evaluate(() => { + window.STATE.exposed = 0; + window.STATE.exposedFirst = 0; + window.watcher1 = new spaniel.Watcher(); + window.watcher2 = new spaniel.Watcher(); + window.target = document.querySelector('.tracked-item[data-id="6"]'); + window.watcher1.watch(window.target, function() { + window.STATE.exposed++; + createDiv('exposed-div-' + window.STATE.exposed); + }); + window.watcher2.watch(window.target, function() { + window.STATE.exposed++; + createDiv('exposed-div-' + window.STATE.exposed); + }); + + var referenceElement = document.querySelector('.tracked-item[data-id="1"]'); + window.watcher.watch(referenceElement, function(e, meta) { + if (e == 'exposed') { + window.STATE.exposedFirst++; + createDiv('first-element-exposed-div-' + window.STATE.exposedFirst); + } + }); + }) + .onDOMReady() + .scrollTo(200) + .waitForExposed(1) + .scrollTo(0) + .waitForNthElemEvent('first', 'exposed', '1') + .evaluate(() => { + window.watcher1.unwatch(window.target); + }) + .wait(100) + .scrollTo(200) + .waitForExposed(3) + .getExecution() + .evaluate(function() { + return window.STATE.exposed; + }) + .then(function(result) { + assert.equal(result, 3, 'Callback fired 3 times'); + }); + } + ['@test watched item callbacks fire in order']() { + return this.context + .evaluate(() => { + window.STATE.order = []; + window.watcher = new spaniel.Watcher(); + var t1 = document.querySelector('.tracked-item[data-id="1"]'); + var t2 = document.querySelector('.tracked-item[data-id="2"]'); + window.watcher.watch(t1, function() { + window.STATE.order.push(1); + createDiv('exposed-div-1'); + }); + window.watcher.watch(t2, function() { + createDiv('exposed-div-2'); + window.STATE.order.push(2); + }); + }) + .onDOMReady() + .waitForExposed(1) + .waitForExposed(2) + .getExecution() + .evaluate(function() { + return window.STATE.order; + }) + .then(function(result) { + assert.equal(result[0], 1, 'First watched item callback fires first'); + assert.equal(result[1], 2, 'Second watched item callback fires second'); + }); + } } -}); \ No newline at end of file +); diff --git a/test/headless/specs/watcher/impression-event.spec.js b/test/headless/specs/watcher/impression-event.spec.js index f39103f..4a6b122 100644 --- a/test/headless/specs/watcher/impression-event.spec.js +++ b/test/headless/specs/watcher/impression-event.spec.js @@ -1,117 +1,123 @@ /* Copyright 2017 LinkedIn Corp. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
 You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - + Unless required by applicable law or agreed to in writing, software 
distributed under the License is distributed on an "AS IS" BASIS, 
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. */ import { assert } from 'chai'; -import { - default as testModule, - WatcherTestClass -} from './../../test-module'; +import { default as testModule, WatcherTestClass } from './../../test-module'; import constants from './../../../constants.js'; -const { time: { IMPRESSION_THRESHOLD, RAF_THRESHOLD, SMALL }, ITEM_TO_OBSERVE, NUM_SKIPPED_FRAMES } = constants; +const { + time: { IMPRESSION_THRESHOLD, RAF_THRESHOLD, SMALL }, + ITEM_TO_OBSERVE, + NUM_SKIPPED_FRAMES +} = constants; class ImpressionEventTestClass extends WatcherTestClass { setupTest(customSetup) { - return this.context.evaluate(customSetup || (() => { - watcher.disconnect(); - var el = document.querySelector('.tracked-item[data-id="5"]') - var id = el.getAttribute('data-id'); - window.watcher.watch(el, function(e, meta) { - if (e == 'visible') { - createDiv('visible-div'); - } else if (e == 'exposed') { - createDiv('exposed-div'); - } - var end = meta && meta.duration ? ' for ' + meta.duration + ' milliseconds' : ''; - console.log(id + ' ' + e + end); - GLOBAL_TEST_EVENTS.push({ - id: parseInt(id), - e: e, - meta: meta || {} - }); - }); + return this.context.evaluate( + customSetup || + (() => { + watcher.disconnect(); + var el = document.querySelector('.tracked-item[data-id="5"]'); + var id = el.getAttribute('data-id'); + window.watcher.watch(el, function(e, meta) { + if (e == 'visible') { + createDiv('visible-div'); + } else if (e == 'exposed') { + createDiv('exposed-div'); + } + var end = meta && meta.duration ? ' for ' + meta.duration + ' milliseconds' : ''; + console.log(id + ' ' + e + end); + GLOBAL_TEST_EVENTS.push({ + id: parseInt(id), + e: e, + meta: meta || {} + }); + }); - var referenceElement = document.querySelector('.tracked-item[data-id="1"]'); - window.STATE.exposedFirst = 0; - window.watcher.watch(referenceElement, function(e, meta) { - if (e == 'exposed') { - window.STATE.exposedFirst++; - createDiv('first-element-exposed-div-' + window.STATE.exposedFirst); - } - }); - })); + var referenceElement = document.querySelector('.tracked-item[data-id="1"]'); + window.STATE.exposedFirst = 0; + window.watcher.watch(referenceElement, function(e, meta) { + if (e == 'exposed') { + window.STATE.exposedFirst++; + createDiv('first-element-exposed-div-' + window.STATE.exposedFirst); + } + }); + }) + ); } } -testModule('Impression event', class extends ImpressionEventTestClass { - ['@test should not fire if item is exposed but not impressed']() { - return this.setupTest() - .onDOMReady() - .scrollTo(50) - .waitForExposed() - .assertOnce(ITEM_TO_OBSERVE, 'exposed') - .assertNever(ITEM_TO_OBSERVE, 'impressed') - .assertOnce(ITEM_TO_OBSERVE, 'exposed') - .done(); - } +testModule( + 'Impression event', + class extends ImpressionEventTestClass { + ['@test should not fire if item is exposed but not impressed']() { + return this.setupTest() + .onDOMReady() + .scrollTo(50) + .waitForExposed() + .assertOnce(ITEM_TO_OBSERVE, 'exposed', 'should be exposed') + .assertNever(ITEM_TO_OBSERVE, 'impressed', 'should not be impressed') + .done(); + } - ['@test should not fire if item is visible, but not enough time lapsed']() { - return this.setupTest() - .onDOMReady() - .scrollTo(200) - .assertNever(ITEM_TO_OBSERVE, 'impressed') - .done(); - } + ['@test should not fire if item is visible, but not enough time lapsed']() { + return this.setupTest() + .onDOMReady() + .scrollTo(200) + .assertNever(ITEM_TO_OBSERVE, 'impressed') + .done(); + } - ['@test should not fire when item is visible, moves several times, but not enough time lapsed']() { - return this.setupTest() - .onDOMReady() - .scrollTo(150) - .scrollTo(250) - .scrollTo(0) - .waitForNthElemEvent('first', 'exposed', '1') - .assertNever(ITEM_TO_OBSERVE, 'impressed') - .done(); - } + ['@test should not fire when item is visible, moves several times, but not enough time lapsed']() { + return this.setupTest() + .onDOMReady() + .scrollTo(150) + .scrollTo(250) + .scrollTo(0) + .waitForNthElemEvent('first', 'exposed', '1') + .assertNever(ITEM_TO_OBSERVE, 'impressed') + .done(); + } - ['@test should fire only once when item is moved into viewport and remains the threshold time']() { - return this.setupTest() - .onDOMReady() - .scrollTo(200) - .waitForVisible() - .wait(IMPRESSION_THRESHOLD + RAF_THRESHOLD) - .assertOnce(ITEM_TO_OBSERVE, 'impressed') - .done(); - } + ['@test should fire only once when item is moved into viewport and remains the threshold time']() { + return this.setupTest() + .onDOMReady() + .scrollTo(200) + .waitForVisible() + .wait(IMPRESSION_THRESHOLD + RAF_THRESHOLD) + .assertOnce(ITEM_TO_OBSERVE, 'impressed') + .done(); + } - ['@test should fire only once when item is moved into viewport, is moved while remaining in viewport, after the threshold time']() { - return this.setupTest() - .onDOMReady() - .scrollTo(300) - .scrollTo(250) - .scrollTo(275) - .assertNever(ITEM_TO_OBSERVE, 'impressed', 'should not be impressed before threshold') - .waitForVisible() - .wait(IMPRESSION_THRESHOLD + RAF_THRESHOLD) - .assertOnce(ITEM_TO_OBSERVE, 'impressed', 'should be impressed after threshold') - .done(); - } + ['@test should fire only once when item is moved into viewport, is moved while remaining in viewport, after the threshold time']() { + return this.setupTest() + .onDOMReady() + .scrollTo(300) + .scrollTo(250) + .scrollTo(275) + .assertNever(ITEM_TO_OBSERVE, 'impressed', 'should not be impressed before threshold') + .waitForVisible() + .wait(IMPRESSION_THRESHOLD + RAF_THRESHOLD) + .assertOnce(ITEM_TO_OBSERVE, 'impressed', 'should be impressed after threshold') + .done(); + } - ['@test should fire only once when item is moved into viewport, out, and then back in, all before threshold time']() { - return this.setupTest() - .onDOMReady() - .scrollTo(200) - .wait(RAF_THRESHOLD) - .scrollTo(0) - .assertNever(ITEM_TO_OBSERVE, 'impressed') - .scrollTo(200) - .waitForVisible() - .wait(IMPRESSION_THRESHOLD + RAF_THRESHOLD) - .assertOnce(ITEM_TO_OBSERVE, 'impressed') - .done(); + ['@test should fire only once when item is moved into viewport, out, and then back in, all before threshold time']() { + return this.setupTest() + .onDOMReady() + .scrollTo(200) + .wait(RAF_THRESHOLD) + .scrollTo(0) + .assertNever(ITEM_TO_OBSERVE, 'impressed') + .scrollTo(200) + .waitForVisible() + .wait(IMPRESSION_THRESHOLD + RAF_THRESHOLD) + .assertOnce(ITEM_TO_OBSERVE, 'impressed') + .done(); + } } -}); +);