<!DOCTYPE html>
<html>
<head>
<title>Shadow DOM: slotchange event</title>
<meta name="author" title="Ryosuke Niwa" href="mailto:rniwa@webkit.org">
<meta name="author" title="Hayato Ito" href="mailto:hayato@google.com">
<link rel="help" href="https://dom.spec.whatwg.org/#signaling-slot-change">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
</head>
<body>
<div id="log"></div>
<script>
function treeName(mode, connectedToDocument)
{
    return (mode == 'open' ? 'an ' : 'a ') + mode + ' shadow root '
        + (connectedToDocument ? '' : ' not') + ' in a document';
}

function testAppendingSpanToShadowRootWithDefaultSlot(mode, connectedToDocument)
{
    var test = async_test('slotchange event must fire on a default slot element inside '
        + treeName(mode, connectedToDocument));

    var host;
    var slot;
    var eventCount = 0;

    test.step(function () {
        host = document.createElement('div');
        if (connectedToDocument)
            document.body.appendChild(host);

        var shadowRoot = host.attachShadow({'mode': mode});
        slot = document.createElement('slot');

        slot.addEventListener('slotchange', function (event) {
            if (event.isFakeEvent)
                return;

            test.step(function () {
                assert_equals(event.type, 'slotchange', 'slotchange event\'s type must be "slotchange"');
                assert_equals(event.target, slot, 'slotchange event\'s target must be the slot element');
                assert_equals(event.relatedTarget, undefined, 'slotchange must not set relatedTarget');
            });
            eventCount++;
        });

        shadowRoot.appendChild(slot);

        host.appendChild(document.createElement('span'));
        host.appendChild(document.createElement('b'));

        assert_equals(eventCount, 0, 'slotchange event must not be fired synchronously');
    });

    setTimeout(function () {
        test.step(function () {
            assert_equals(eventCount, 1, 'slotchange must be fired exactly once after the assigned nodes changed');

            host.appendChild(document.createElement('i'));
        });

        setTimeout(function () {
            test.step(function () {
                assert_equals(eventCount, 2, 'slotchange must be fired exactly once after the assigned nodes changed');

                host.appendChild(document.createTextNode('hello'));

                var fakeEvent = new Event('slotchange');
                fakeEvent.isFakeEvent = true;
                slot.dispatchEvent(fakeEvent);
            });

            setTimeout(function () {
                test.step(function () {
                    assert_equals(eventCount, 3, 'slotchange must be fired exactly once after the assigned nodes changed'
                        + ' event if there was a synthetic slotchange event fired');
                });
                test.done();
            }, 1);
        }, 1);
    }, 1);
}

testAppendingSpanToShadowRootWithDefaultSlot('open', true);
testAppendingSpanToShadowRootWithDefaultSlot('closed', true);
testAppendingSpanToShadowRootWithDefaultSlot('open', false);
testAppendingSpanToShadowRootWithDefaultSlot('closed', false);

function testAppendingSpanToShadowRootWithNamedSlot(mode, connectedToDocument)
{
    var test = async_test('slotchange event must fire on a named slot element inside'
        + treeName(mode, connectedToDocument));

    var host;
    var slot;
    var eventCount = 0;

    test.step(function () {
        host = document.createElement('div');
        if (connectedToDocument)
            document.body.appendChild(host);

        var shadowRoot = host.attachShadow({'mode': mode});
        slot = document.createElement('slot');
        slot.name = 'someSlot';

        slot.addEventListener('slotchange', function (event) {
            if (event.isFakeEvent)
                return;

            test.step(function () {
                assert_equals(event.type, 'slotchange', 'slotchange event\'s type must be "slotchange"');
                assert_equals(event.target, slot, 'slotchange event\'s target must be the slot element');
                assert_equals(event.relatedTarget, undefined, 'slotchange must not set relatedTarget');
            });
            eventCount++;
        });

        shadowRoot.appendChild(slot);

        var span = document.createElement('span');
        span.slot = 'someSlot';
        host.appendChild(span);

        var b = document.createElement('b');
        b.slot = 'someSlot';
        host.appendChild(b);

        assert_equals(eventCount, 0, 'slotchange event must not be fired synchronously');
    });

    setTimeout(function () {
        test.step(function () {
            assert_equals(eventCount, 1, 'slotchange must be fired exactly once after the assigned nodes changed');

            var i = document.createElement('i');
            i.slot = 'someSlot';
            host.appendChild(i);
        });

        setTimeout(function () {
            test.step(function () {
                assert_equals(eventCount, 2, 'slotchange must be fired exactly once after the assigned nodes changed');

                var em = document.createElement('em');
                em.slot = 'someSlot';
                host.appendChild(em);

                var fakeEvent = new Event('slotchange');
                fakeEvent.isFakeEvent = true;
                slot.dispatchEvent(fakeEvent);
            });

            setTimeout(function () {
                test.step(function () {
                    assert_equals(eventCount, 3, 'slotchange must be fired exactly once after the assigned nodes changed'
                        + ' event if there was a synthetic slotchange event fired');
                });
                test.done();
            }, 1);

        }, 1);
    }, 1);
}

testAppendingSpanToShadowRootWithNamedSlot('open', true);
testAppendingSpanToShadowRootWithNamedSlot('closed', true);
testAppendingSpanToShadowRootWithNamedSlot('open', false);
testAppendingSpanToShadowRootWithNamedSlot('closed', false);

function testSlotchangeDoesNotFireWhenOtherSlotsChange(mode, connectedToDocument)
{
    var test = async_test('slotchange event must not fire on a slot element inside '
        + treeName(mode, connectedToDocument)
        + ' when another slot\'s assigned nodes change');

    var host;
    var defaultSlotEventCount = 0;
    var namedSlotEventCount = 0;

    test.step(function () {
        host = document.createElement('div');
        if (connectedToDocument)
            document.body.appendChild(host);

        var shadowRoot = host.attachShadow({'mode': mode});
        var defaultSlot = document.createElement('slot');
        defaultSlot.addEventListener('slotchange', function (event) {
            test.step(function () {
                assert_equals(event.target, defaultSlot, 'slotchange event\'s target must be the slot element');
            });
            defaultSlotEventCount++;
        });

        var namedSlot = document.createElement('slot');
        namedSlot.name = 'slotName';
        namedSlot.addEventListener('slotchange', function (event) {
            test.step(function () {
                assert_equals(event.target, namedSlot, 'slotchange event\'s target must be the slot element');
            });
            namedSlotEventCount++;
        });

        shadowRoot.appendChild(defaultSlot);
        shadowRoot.appendChild(namedSlot);

        host.appendChild(document.createElement('span'));

        assert_equals(defaultSlotEventCount, 0, 'slotchange event must not be fired synchronously');
        assert_equals(namedSlotEventCount, 0, 'slotchange event must not be fired synchronously');
    });

    setTimeout(function () {
        test.step(function () {
            assert_equals(defaultSlotEventCount, 1,
                'slotchange must be fired exactly once after the assigned nodes change on a default slot');
            assert_equals(namedSlotEventCount, 0,
                'slotchange must not be fired on a named slot after the assigned nodes change on a default slot');

            var span = document.createElement('span');
            span.slot = 'slotName';
            host.appendChild(span);
        });

        setTimeout(function () {
            test.step(function () {
                assert_equals(defaultSlotEventCount, 1,
                    'slotchange must not be fired on a default slot after the assigned nodes change on a named slot');
                assert_equals(namedSlotEventCount, 1,
                    'slotchange must be fired exactly once after the assigned nodes change on a default slot');
            });
            test.done();
        }, 1);
    }, 1);
}

testSlotchangeDoesNotFireWhenOtherSlotsChange('open', true);
testSlotchangeDoesNotFireWhenOtherSlotsChange('closed', true);
testSlotchangeDoesNotFireWhenOtherSlotsChange('open', false);
testSlotchangeDoesNotFireWhenOtherSlotsChange('closed', false);

function testSlotchangeDoesFireAtInsertedAndDoesNotFireForMutationAfterRemoved(mode, connectedToDocument)
{
    var test = async_test('slotchange event must fire on a slot element when a shadow host has a slottable and the slot was inserted'
                        + ' and must not fire when the shadow host was mutated after the slot was removed inside '
                        + treeName(mode, connectedToDocument));

    var host;
    var slot;
    var eventCount = 0;

    test.step(function () {
        host = document.createElement('div');
        if (connectedToDocument)
            document.body.appendChild(host);

        var shadowRoot = host.attachShadow({'mode': mode});
        slot = document.createElement('slot');
        slot.addEventListener('slotchange', function (event) {
            test.step(function () {
                assert_equals(event.target, slot, 'slotchange event\'s target must be the slot element');
            });
            eventCount++;
        });

        host.appendChild(document.createElement('span'));
        shadowRoot.appendChild(slot);

        assert_equals(eventCount, 0, 'slotchange event must not be fired synchronously');
    });

    setTimeout(function () {
        test.step(function () {
            assert_equals(eventCount, 1,
                          'slotchange must be fired on a slot element if there is assigned nodes when the slot was inserted');
            host.removeChild(host.firstChild);
        });

        setTimeout(function () {
            test.step(function () {
                assert_equals(eventCount, 2,
                              'slotchange must be fired after the assigned nodes change on a slot while the slot element was in the tree');
                slot.parentNode.removeChild(slot);
                host.appendChild(document.createElement('span'));
            });

            setTimeout(function () {
                assert_equals(eventCount, 2,
                              'slotchange must not be fired on a slot element if the assigned nodes changed after the slot was removed');
                test.done();
            }, 1);
        }, 1);
    }, 1);
}

testSlotchangeDoesFireAtInsertedAndDoesNotFireForMutationAfterRemoved('open', true);
testSlotchangeDoesFireAtInsertedAndDoesNotFireForMutationAfterRemoved('closed', true);
testSlotchangeDoesFireAtInsertedAndDoesNotFireForMutationAfterRemoved('open', false);
testSlotchangeDoesFireAtInsertedAndDoesNotFireForMutationAfterRemoved('closed', false);

function testSlotchangeFiresOnTransientlyPresentSlot(mode, connectedToDocument)
{
    var test = async_test('slotchange event must fire on a slot element inside '
        + treeName(mode, connectedToDocument)
        + ' even if the slot was removed immediately after the assigned nodes were mutated');

    var host;
    var slot;
    var anotherSlot;
    var slotEventCount = 0;
    var anotherSlotEventCount = 0;

    test.step(function () {
        host = document.createElement('div');
        if (connectedToDocument)
            document.body.appendChild(host);

        var shadowRoot = host.attachShadow({'mode': mode});
        slot = document.createElement('slot');
        slot.name = 'someSlot';
        slot.addEventListener('slotchange', function (event) {
            test.step(function () {
                assert_equals(event.target, slot, 'slotchange event\'s target must be the slot element');
            });
            slotEventCount++;
        });

        anotherSlot = document.createElement('slot');
        anotherSlot.name = 'someSlot';
        anotherSlot.addEventListener('slotchange', function (event) {
            test.step(function () {
                assert_equals(event.target, anotherSlot, 'slotchange event\'s target must be the slot element');
            });
            anotherSlotEventCount++;
        });

        shadowRoot.appendChild(slot);

        var span = document.createElement('span');
        span.slot = 'someSlot';
        host.appendChild(span);

        shadowRoot.removeChild(slot);
        shadowRoot.appendChild(anotherSlot);

        var span = document.createElement('span');
        span.slot = 'someSlot';
        host.appendChild(span);

        shadowRoot.removeChild(anotherSlot);

        assert_equals(slotEventCount, 0, 'slotchange event must not be fired synchronously');
        assert_equals(anotherSlotEventCount, 0, 'slotchange event must not be fired synchronously');
    });

    setTimeout(function () {
        test.step(function () {
            assert_equals(slotEventCount, 1,
                'slotchange must be fired on a slot element if the assigned nodes changed while the slot was present');
            assert_equals(anotherSlotEventCount, 1,
                'slotchange must be fired on a slot element if the assigned nodes changed while the slot was present');
        });
        test.done();
    }, 1);
}

testSlotchangeFiresOnTransientlyPresentSlot('open', true);
testSlotchangeFiresOnTransientlyPresentSlot('closed', true);
testSlotchangeFiresOnTransientlyPresentSlot('open', false);
testSlotchangeFiresOnTransientlyPresentSlot('closed', false);

function testSlotchangeFiresOnInnerHTML(mode, connectedToDocument)
{
    var test = async_test('slotchange event must fire on a slot element inside '
        + treeName(mode, connectedToDocument)
        + ' when innerHTML modifies the children of the shadow host');

    var host;
    var defaultSlot;
    var namedSlot;
    var defaultSlotEventCount = 0;
    var namedSlotEventCount = 0;

    test.step(function () {
        host = document.createElement('div');
        if (connectedToDocument)
            document.body.appendChild(host);

        var shadowRoot = host.attachShadow({'mode': mode});
        defaultSlot = document.createElement('slot');
        defaultSlot.addEventListener('slotchange', function (event) {
            test.step(function () {
                assert_equals(event.target, defaultSlot, 'slotchange event\'s target must be the slot element');
            });
            defaultSlotEventCount++;
        });

        namedSlot = document.createElement('slot');
        namedSlot.name = 'someSlot';
        namedSlot.addEventListener('slotchange', function (event) {
            test.step(function () {
                assert_equals(event.target, namedSlot, 'slotchange event\'s target must be the slot element');
            });
            namedSlotEventCount++;
        });
        shadowRoot.appendChild(namedSlot);
        shadowRoot.appendChild(defaultSlot);
        host.innerHTML = 'foo <b>bar</b>';

        assert_equals(defaultSlotEventCount, 0, 'slotchange event must not be fired synchronously');
        assert_equals(namedSlotEventCount, 0, 'slotchange event must not be fired synchronously');
    });

    setTimeout(function () {
        test.step(function () {
            assert_equals(defaultSlotEventCount, 1,
                'slotchange must be fired on a slot element if the assigned nodes are changed by innerHTML');
            assert_equals(namedSlotEventCount, 0,
                'slotchange must not be fired on a slot element if the assigned nodes are not changed by innerHTML');
            host.innerHTML = 'baz';
        });
        setTimeout(function () {
            test.step(function () {
                assert_equals(defaultSlotEventCount, 2,
                    'slotchange must be fired on a slot element if the assigned nodes are changed by innerHTML');
                assert_equals(namedSlotEventCount, 0,
                    'slotchange must not be fired on a slot element if the assigned nodes are not changed by innerHTML');
                host.innerHTML = '';
            });
            setTimeout(function () {
                test.step(function () {
                    assert_equals(defaultSlotEventCount, 3,
                        'slotchange must be fired on a slot element if the assigned nodes are changed by innerHTML');
                    assert_equals(namedSlotEventCount, 0,
                        'slotchange must not be fired on a slot element if the assigned nodes are not changed by innerHTML');
                    host.innerHTML = '<b slot="someSlot">content</b>';
                });
                setTimeout(function () {
                    test.step(function () {
                        assert_equals(defaultSlotEventCount, 3,
                            'slotchange must not be fired on a slot element if the assigned nodes are not changed by innerHTML');
                        assert_equals(namedSlotEventCount, 1,
                            'slotchange must not be fired on a slot element if the assigned nodes are changed by innerHTML');
                        host.innerHTML = '';
                    });
                    setTimeout(function () {
                        test.step(function () {
                            // FIXME: This test would fail in the current implementation because we can't tell
                            // whether a text node was removed in AllChildrenRemoved or not.
//                            assert_equals(defaultSlotEventCount, 3,
//                                'slotchange must not be fired on a slot element if the assigned nodes are not changed by innerHTML');
                            assert_equals(namedSlotEventCount, 2,
                                'slotchange must not be fired on a slot element if the assigned nodes are changed by innerHTML');
                        });
                        test.done();
                    }, 1);
                }, 1);
            }, 1);
        }, 1);
    }, 1);
}

testSlotchangeFiresOnInnerHTML('open', true);
testSlotchangeFiresOnInnerHTML('closed', true);
testSlotchangeFiresOnInnerHTML('open', false);
testSlotchangeFiresOnInnerHTML('closed', false);

function testSlotchangeFiresWhenNestedSlotChange(mode, connectedToDocument)
{
    var test = async_test('slotchange event must fire on a slot element inside '
        + treeName(mode, connectedToDocument)
        + ' when nested slots\'s contents change');

    var outerHost;
    var innerHost;
    var outerSlot;
    var innerSlot;
    var outerSlotEventCount = 0;
    var innerSlotEventCount = 0;

    test.step(function () {
        outerHost = document.createElement('div');
        if (connectedToDocument)
            document.body.appendChild(outerHost);

        var outerShadow = outerHost.attachShadow({'mode': mode});
        outerShadow.appendChild(document.createElement('span'));
        outerSlot = document.createElement('slot');
        outerSlot.addEventListener('slotchange', function (event) {
            test.step(function () {
                assert_equals(event.target, outerSlot, 'slotchange event\'s target must be the slot element');
            });
            outerSlotEventCount++;
        });

        innerHost = document.createElement('div');
        innerHost.appendChild(outerSlot);
        outerShadow.appendChild(innerHost);

        var innerShadow = innerHost.attachShadow({'mode': mode});
        innerShadow.appendChild(document.createElement('span'));
        innerSlot = document.createElement('slot');
        innerSlot.addEventListener('slotchange', function (event) {
            event.stopPropagation();
            test.step(function () {
                if (innerSlotEventCount === 0) {
                    assert_equals(event.target, innerSlot, 'slotchange event\'s target must be the inner slot element at 1st slotchange');
                } else if (innerSlotEventCount === 1) {
                    assert_equals(event.target, outerSlot, 'slotchange event\'s target must be the outer slot element at 2nd sltochange');
                }
            });
            innerSlotEventCount++;
        });
        innerShadow.appendChild(innerSlot);

        outerHost.appendChild(document.createElement('span'));

        assert_equals(innerSlotEventCount, 0, 'slotchange event must not be fired synchronously');
        assert_equals(outerSlotEventCount, 0, 'slotchange event must not be fired synchronously');
    });

    setTimeout(function () {
        test.step(function () {
            assert_equals(outerSlotEventCount, 1,
                'slotchange must be fired on a slot element if the assigned nodes changed');
            assert_equals(innerSlotEventCount, 2,
                'slotchange must be fired on a slot element and must bubble');
        });
        test.done();
    }, 1);
}

testSlotchangeFiresWhenNestedSlotChange('open', true);
testSlotchangeFiresWhenNestedSlotChange('closed', true);
testSlotchangeFiresWhenNestedSlotChange('open', false);
testSlotchangeFiresWhenNestedSlotChange('closed', false);

function testSlotchangeFiresAtEndOfMicroTask(mode, connectedToDocument)
{
    var test = async_test('slotchange event must fire at the end of current microtask after mutation observers are invoked inside '
        + treeName(mode, connectedToDocument) + ' when slots\'s contents change');

    var host;
    var slot;
    var eventCount = 0;

    test.step(function () {
        host = document.createElement('div');
        if (connectedToDocument)
            document.body.appendChild(host);

        var shadowRoot = host.attachShadow({'mode': mode});
        slot = document.createElement('slot');

        slot.addEventListener('slotchange', function (event) {
            test.step(function () {
                assert_equals(event.type, 'slotchange', 'slotchange event\'s type must be "slotchange"');
                assert_equals(event.target, slot, 'slotchange event\'s target must be the slot element');
            });
            eventCount++;
        });

        shadowRoot.appendChild(slot);
    });

    var element = document.createElement('div');

    new MutationObserver(function () {
        test.step(function () {
            assert_equals(eventCount, 0, 'slotchange event must not be fired before mutation records are delivered');
        });
        host.appendChild(document.createElement('span'));
        element.setAttribute('title', 'bar');
    }).observe(element, {attributes: true, attributeFilter: ['id']});

    new MutationObserver(function () {
        test.step(function () {
            assert_equals(eventCount, 1, 'slotchange event must be fired during a single compound microtask');
        });
    }).observe(element, {attributes: true, attributeFilter: ['title']});

    element.setAttribute('id', 'foo');
    host.appendChild(document.createElement('div'));

    setTimeout(function () {
        test.step(function () {
            assert_equals(eventCount, 2,
                'a distinct slotchange event must be enqueued for changes made during a mutation observer delivery');
        });
        test.done();
    }, 0);
}

testSlotchangeFiresAtEndOfMicroTask('open', true);
testSlotchangeFiresAtEndOfMicroTask('closed', true);
testSlotchangeFiresAtEndOfMicroTask('open', false);
testSlotchangeFiresAtEndOfMicroTask('closed', false);
</script>
</body>
</html>
