author | Carsten "Tomcat" Book <cbook@mozilla.com> |
Fri, 13 Mar 2015 14:06:57 +0100 | |
changeset 233540 | 3d6e70f93eaf2382a80904dffb52640ec4d47018 |
parent 233539 | 67faef0b6c20a787e22e9085fd48dc1d54ced13e (current diff) |
parent 233496 | 9dbb2d41bb2ca889db2631f1f0993cd0c1e6165c (diff) |
child 233541 | 106237ea5c7e4ef2817c7781b1a4dc6ca49253b2 |
push id | 28417 |
push user | ryanvm@gmail.com |
push date | Fri, 13 Mar 2015 19:52:44 +0000 |
treeherder | mozilla-central@977add19414a [default view] [failures only] |
perfherder | [talos] [build metrics] [platform microbench] (compared to previous push) |
milestone | 39.0a1 |
first release with | nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
|
last release without | nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
|
--- a/b2g/chrome/content/devtools/debugger.js +++ b/b2g/chrome/content/devtools/debugger.js @@ -54,17 +54,21 @@ let RemoteDebugger = { return DebuggerServer.AuthenticationResult.DENY; } this._listen(); this._promptingForAllow = new Promise(resolve => { this._handleAllowResult = detail => { this._handleAllowResult = null; this._promptingForAllow = null; - if (detail.value) { + // Newer Gaia supplies |authResult|, which is one of the + // AuthenticationResult values. + if (detail.authResult) { + resolve(detail.authResult); + } else if (detail.value) { resolve(DebuggerServer.AuthenticationResult.ALLOW); } else { resolve(DebuggerServer.AuthenticationResult.DENY); } }; shell.sendChromeEvent({ type: "remote-debugger-prompt",
--- a/b2g/config/dolphin/sources.xml +++ b/b2g/config/dolphin/sources.xml @@ -10,17 +10,17 @@ <!--original fetch url was git://codeaurora.org/--> <remote fetch="https://git.mozilla.org/external/caf" name="caf"/> <!--original fetch url was https://git.mozilla.org/releases--> <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/> <!-- B2G specific things. --> <project name="platform_build" path="build" remote="b2g" revision="ef937d1aca7c4cf89ecb5cc43ae8c21c2000a9db"> <copyfile dest="Makefile" src="core/root.mk"/> </project> - <project name="gaia" path="gaia" remote="mozillaorg" revision="eabe35cf054d47087b37c1ca7db8143717fbd7f3"/> + <project name="gaia" path="gaia" remote="mozillaorg" revision="10dcd335d588997fc12845e9197de89228664f95"/> <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/> <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="c82a532ee1f14b9733214022b1e2d55a0b030be8"/> <project name="librecovery" path="librecovery" remote="b2g" revision="1b3591a50ed352fc6ddb77462b7b35d0bfa555a3"/> <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/> <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/> <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/> <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/> <project name="apitrace" path="external/apitrace" remote="apitrace" revision="1a1e326a7804a62de8f61fab705f7e1974cad818"/>
--- a/b2g/config/emulator-ics/sources.xml +++ b/b2g/config/emulator-ics/sources.xml @@ -14,17 +14,17 @@ <!--original fetch url was git://github.com/apitrace/--> <remote fetch="https://git.mozilla.org/external/apitrace" name="apitrace"/> <default remote="caf" revision="refs/tags/android-4.0.4_r2.1" sync-j="4"/> <!-- Gonk specific things and forks --> <project name="platform_build" path="build" remote="b2g" revision="173b3104bfcbd23fc9dccd4b0035fc49aae3d444"> <copyfile dest="Makefile" src="core/root.mk"/> </project> <project name="fake-dalvik" path="dalvik" remote="b2g" revision="ca1f327d5acc198bb4be62fa51db2c039032c9ce"/> - <project name="gaia.git" path="gaia" remote="mozillaorg" revision="eabe35cf054d47087b37c1ca7db8143717fbd7f3"/> + <project name="gaia.git" path="gaia" remote="mozillaorg" revision="10dcd335d588997fc12845e9197de89228664f95"/> <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="c82a532ee1f14b9733214022b1e2d55a0b030be8"/> <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/> <project name="platform_hardware_ril" path="hardware/ril" remote="b2g" revision="93f9ba577f68d772093987c2f1c0a4ae293e1802"/> <project name="platform_external_qemu" path="external/qemu" remote="b2g" revision="97c3d9b8b87774ca7a08c89145e95b55652459ef"/> <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/> <project name="apitrace" path="external/apitrace" remote="apitrace" revision="1a1e326a7804a62de8f61fab705f7e1974cad818"/> <!-- Stock Android things --> <project name="platform/abi/cpp" path="abi/cpp" revision="dd924f92906085b831bf1cbbc7484d3c043d613c"/>
--- a/b2g/config/emulator-jb/sources.xml +++ b/b2g/config/emulator-jb/sources.xml @@ -12,17 +12,17 @@ <!--original fetch url was https://git.mozilla.org/releases--> <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/> <!-- B2G specific things. --> <project name="platform_build" path="build" remote="b2g" revision="4efd19d199ae52656604f794c5a77518400220fd"> <copyfile dest="Makefile" src="core/root.mk"/> </project> <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/> <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/> - <project name="gaia" path="gaia" remote="mozillaorg" revision="eabe35cf054d47087b37c1ca7db8143717fbd7f3"/> + <project name="gaia" path="gaia" remote="mozillaorg" revision="10dcd335d588997fc12845e9197de89228664f95"/> <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="c82a532ee1f14b9733214022b1e2d55a0b030be8"/> <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/> <project name="apitrace" path="external/apitrace" remote="apitrace" revision="1a1e326a7804a62de8f61fab705f7e1974cad818"/> <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/> <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/> <!-- Stock Android things --> <project groups="linux" name="platform/prebuilts/clang/linux-x86/3.1" path="prebuilts/clang/linux-x86/3.1" revision="5c45f43419d5582949284eee9cef0c43d866e03b"/> <project groups="linux" name="platform/prebuilts/clang/linux-x86/3.2" path="prebuilts/clang/linux-x86/3.2" revision="3748b4168e7bd8d46457d4b6786003bc6a5223ce"/>
--- a/b2g/config/emulator-kk/sources.xml +++ b/b2g/config/emulator-kk/sources.xml @@ -10,17 +10,17 @@ <!--original fetch url was git://codeaurora.org/--> <remote fetch="https://git.mozilla.org/external/caf" name="caf"/> <!--original fetch url was https://git.mozilla.org/releases--> <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/> <!-- B2G specific things. --> <project name="platform_build" path="build" remote="b2g" revision="ef937d1aca7c4cf89ecb5cc43ae8c21c2000a9db"> <copyfile dest="Makefile" src="core/root.mk"/> </project> - <project name="gaia" path="gaia" remote="mozillaorg" revision="eabe35cf054d47087b37c1ca7db8143717fbd7f3"/> + <project name="gaia" path="gaia" remote="mozillaorg" revision="10dcd335d588997fc12845e9197de89228664f95"/> <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/> <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="c82a532ee1f14b9733214022b1e2d55a0b030be8"/> <project name="librecovery" path="librecovery" remote="b2g" revision="1b3591a50ed352fc6ddb77462b7b35d0bfa555a3"/> <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/> <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/> <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/> <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/> <project name="apitrace" path="external/apitrace" remote="apitrace" revision="1a1e326a7804a62de8f61fab705f7e1974cad818"/>
--- a/b2g/config/emulator-l/sources.xml +++ b/b2g/config/emulator-l/sources.xml @@ -10,17 +10,17 @@ <!--original fetch url was git://codeaurora.org/--> <remote fetch="https://git.mozilla.org/external/caf" name="caf"/> <!--original fetch url was https://git.mozilla.org/releases--> <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/> <!-- B2G specific things. --> <project name="platform_build" path="build" remote="b2g" revision="52775e03a2d8532429dff579cb2cd56718e488c3"> <copyfile dest="Makefile" src="core/root.mk"/> </project> - <project name="gaia" path="gaia" remote="mozillaorg" revision="eabe35cf054d47087b37c1ca7db8143717fbd7f3"/> + <project name="gaia" path="gaia" remote="mozillaorg" revision="10dcd335d588997fc12845e9197de89228664f95"/> <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/> <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="c82a532ee1f14b9733214022b1e2d55a0b030be8"/> <project name="librecovery" path="librecovery" remote="b2g" revision="1b3591a50ed352fc6ddb77462b7b35d0bfa555a3"/> <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/> <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/> <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/> <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/> <project name="apitrace" path="external/apitrace" remote="apitrace" revision="1a1e326a7804a62de8f61fab705f7e1974cad818"/>
--- a/b2g/config/emulator/sources.xml +++ b/b2g/config/emulator/sources.xml @@ -14,17 +14,17 @@ <!--original fetch url was git://github.com/apitrace/--> <remote fetch="https://git.mozilla.org/external/apitrace" name="apitrace"/> <default remote="caf" revision="refs/tags/android-4.0.4_r2.1" sync-j="4"/> <!-- Gonk specific things and forks --> <project name="platform_build" path="build" remote="b2g" revision="173b3104bfcbd23fc9dccd4b0035fc49aae3d444"> <copyfile dest="Makefile" src="core/root.mk"/> </project> <project name="fake-dalvik" path="dalvik" remote="b2g" revision="ca1f327d5acc198bb4be62fa51db2c039032c9ce"/> - <project name="gaia.git" path="gaia" remote="mozillaorg" revision="eabe35cf054d47087b37c1ca7db8143717fbd7f3"/> + <project name="gaia.git" path="gaia" remote="mozillaorg" revision="10dcd335d588997fc12845e9197de89228664f95"/> <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="c82a532ee1f14b9733214022b1e2d55a0b030be8"/> <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/> <project name="platform_hardware_ril" path="hardware/ril" remote="b2g" revision="93f9ba577f68d772093987c2f1c0a4ae293e1802"/> <project name="platform_external_qemu" path="external/qemu" remote="b2g" revision="97c3d9b8b87774ca7a08c89145e95b55652459ef"/> <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/> <project name="apitrace" path="external/apitrace" remote="apitrace" revision="1a1e326a7804a62de8f61fab705f7e1974cad818"/> <!-- Stock Android things --> <project name="platform/abi/cpp" path="abi/cpp" revision="dd924f92906085b831bf1cbbc7484d3c043d613c"/>
--- a/b2g/config/flame-kk/sources.xml +++ b/b2g/config/flame-kk/sources.xml @@ -10,17 +10,17 @@ <!--original fetch url was git://codeaurora.org/--> <remote fetch="https://git.mozilla.org/external/caf" name="caf"/> <!--original fetch url was https://git.mozilla.org/releases--> <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/> <!-- B2G specific things. --> <project name="platform_build" path="build" remote="b2g" revision="ef937d1aca7c4cf89ecb5cc43ae8c21c2000a9db"> <copyfile dest="Makefile" src="core/root.mk"/> </project> - <project name="gaia" path="gaia" remote="mozillaorg" revision="eabe35cf054d47087b37c1ca7db8143717fbd7f3"/> + <project name="gaia" path="gaia" remote="mozillaorg" revision="10dcd335d588997fc12845e9197de89228664f95"/> <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/> <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="c82a532ee1f14b9733214022b1e2d55a0b030be8"/> <project name="librecovery" path="librecovery" remote="b2g" revision="1b3591a50ed352fc6ddb77462b7b35d0bfa555a3"/> <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/> <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/> <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/> <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/> <project name="apitrace" path="external/apitrace" remote="apitrace" revision="1a1e326a7804a62de8f61fab705f7e1974cad818"/>
--- a/b2g/config/flame/sources.xml +++ b/b2g/config/flame/sources.xml @@ -12,17 +12,17 @@ <!--original fetch url was https://git.mozilla.org/releases--> <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/> <!-- B2G specific things. --> <project name="platform_build" path="build" remote="b2g" revision="4efd19d199ae52656604f794c5a77518400220fd"> <copyfile dest="Makefile" src="core/root.mk"/> </project> <project name="librecovery" path="librecovery" remote="b2g" revision="1b3591a50ed352fc6ddb77462b7b35d0bfa555a3"/> <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/> - <project name="gaia" path="gaia" remote="mozillaorg" revision="eabe35cf054d47087b37c1ca7db8143717fbd7f3"/> + <project name="gaia" path="gaia" remote="mozillaorg" revision="10dcd335d588997fc12845e9197de89228664f95"/> <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="c82a532ee1f14b9733214022b1e2d55a0b030be8"/> <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/> <project name="apitrace" path="external/apitrace" remote="apitrace" revision="1a1e326a7804a62de8f61fab705f7e1974cad818"/> <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/> <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/> <!-- Stock Android things --> <project groups="linux" name="platform/prebuilts/clang/linux-x86/3.1" path="prebuilts/clang/linux-x86/3.1" revision="e95b4ce22c825da44d14299e1190ea39a5260bde"/> <project groups="linux" name="platform/prebuilts/clang/linux-x86/3.2" path="prebuilts/clang/linux-x86/3.2" revision="471afab478649078ad7c75ec6b252481a59e19b8"/>
--- a/b2g/config/gaia.json +++ b/b2g/config/gaia.json @@ -1,9 +1,9 @@ { "git": { - "git_revision": "eabe35cf054d47087b37c1ca7db8143717fbd7f3", + "git_revision": "10dcd335d588997fc12845e9197de89228664f95", "remote": "https://git.mozilla.org/releases/gaia.git", "branch": "" }, - "revision": "9139624122bb1343e99250a9643bca07f57579f7", + "revision": "f378c0a3ef97b44ddb7f71a7a65faddc391c4e0e", "repo_path": "integration/gaia-central" }
--- a/b2g/config/nexus-4/sources.xml +++ b/b2g/config/nexus-4/sources.xml @@ -12,17 +12,17 @@ <!--original fetch url was https://git.mozilla.org/releases--> <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/> <!-- B2G specific things. --> <project name="platform_build" path="build" remote="b2g" revision="4efd19d199ae52656604f794c5a77518400220fd"> <copyfile dest="Makefile" src="core/root.mk"/> </project> <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/> <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/> - <project name="gaia" path="gaia" remote="mozillaorg" revision="eabe35cf054d47087b37c1ca7db8143717fbd7f3"/> + <project name="gaia" path="gaia" remote="mozillaorg" revision="10dcd335d588997fc12845e9197de89228664f95"/> <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="c82a532ee1f14b9733214022b1e2d55a0b030be8"/> <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/> <project name="apitrace" path="external/apitrace" remote="apitrace" revision="1a1e326a7804a62de8f61fab705f7e1974cad818"/> <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/> <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/> <!-- Stock Android things --> <project groups="linux" name="platform/prebuilts/clang/linux-x86/3.1" path="prebuilts/clang/linux-x86/3.1" revision="5c45f43419d5582949284eee9cef0c43d866e03b"/> <project groups="linux" name="platform/prebuilts/clang/linux-x86/3.2" path="prebuilts/clang/linux-x86/3.2" revision="3748b4168e7bd8d46457d4b6786003bc6a5223ce"/>
--- a/b2g/config/nexus-5-l/sources.xml +++ b/b2g/config/nexus-5-l/sources.xml @@ -10,17 +10,17 @@ <!--original fetch url was git://codeaurora.org/--> <remote fetch="https://git.mozilla.org/external/caf" name="caf"/> <!--original fetch url was https://git.mozilla.org/releases--> <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/> <!-- B2G specific things. --> <project name="platform_build" path="build" remote="b2g" revision="52775e03a2d8532429dff579cb2cd56718e488c3"> <copyfile dest="Makefile" src="core/root.mk"/> </project> - <project name="gaia" path="gaia" remote="mozillaorg" revision="eabe35cf054d47087b37c1ca7db8143717fbd7f3"/> + <project name="gaia" path="gaia" remote="mozillaorg" revision="10dcd335d588997fc12845e9197de89228664f95"/> <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/> <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="c82a532ee1f14b9733214022b1e2d55a0b030be8"/> <project name="librecovery" path="librecovery" remote="b2g" revision="1b3591a50ed352fc6ddb77462b7b35d0bfa555a3"/> <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/> <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/> <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/> <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/> <project name="apitrace" path="external/apitrace" remote="apitrace" revision="1a1e326a7804a62de8f61fab705f7e1974cad818"/>
--- a/browser/base/content/tabbrowser.xml +++ b/browser/base/content/tabbrowser.xml @@ -1493,17 +1493,17 @@ // Change the "remote" attribute. let parent = aBrowser.parentNode; let permanentKey = aBrowser.permanentKey; parent.removeChild(aBrowser); aBrowser.setAttribute("remote", aShouldBeRemote ? "true" : "false"); // Tearing down the browser gives a new permanentKey but we want to // keep the old one. Re-set it explicitly after unbinding from DOM. - aBrowser.permanentKey = permanentKey; + aBrowser._permanentKey = permanentKey; parent.appendChild(aBrowser); // Restore the progress listener. aBrowser.webProgress.addProgressListener(filter, Ci.nsIWebProgress.NOTIFY_ALL); if (aShouldBeRemote) { tab.setAttribute("remote", "true"); // Switching the browser to be remote will connect to a new child
--- a/browser/base/content/test/general/browser.ini +++ b/browser/base/content/test/general/browser.ini @@ -405,16 +405,17 @@ support-files = [browser_subframe_favicons_not_used.js] [browser_syncui.js] skip-if = e10s # Bug 1137087 - browser_tabopen_reflows.js fails if this was previously run with e10s [browser_tabDrop.js] skip-if = buildapp == 'mulet' || e10s [browser_tabMatchesInAwesomebar.js] [browser_tabMatchesInAwesomebar_perwindowpb.js] skip-if = e10s || os == 'linux' # Bug 1093373, bug 1104755 +[browser_tab_detach_restore.js] [browser_tab_drag_drop_perwindow.js] skip-if = buildapp == 'mulet' [browser_tab_dragdrop.js] skip-if = buildapp == 'mulet' [browser_tab_dragdrop2.js] skip-if = buildapp == 'mulet' [browser_tabbar_big_widgets.js] skip-if = os == "linux" || os == "mac" # No tabs in titlebar on linux
new file mode 100644 --- /dev/null +++ b/browser/base/content/test/general/browser_tab_detach_restore.js @@ -0,0 +1,31 @@ +"use strict"; + +add_task(function*() { + let uri = "http://example.com/browser/browser/base/content/test/general/dummy_page.html"; + + // Clear out the closed windows set to start + while (SessionStore.getClosedWindowCount() > 0) + SessionStore.forgetClosedWindow(0); + + let tab = gBrowser.addTab(); + tab.linkedBrowser.loadURI(uri); + yield BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + let key = tab.linkedBrowser.permanentKey; + let win = gBrowser.replaceTabWithWindow(tab); + yield new Promise(resolve => whenDelayedStartupFinished(win, resolve)); + + is(win.gBrowser.selectedBrowser.permanentKey, key, "Should have properly copied the permanentKey"); + yield promiseWindowClosed(win); + + is(SessionStore.getClosedWindowCount(), 1, "Should have restore data for the closed window"); + + win = SessionStore.undoCloseWindow(0); + yield BrowserTestUtils.waitForEvent(win, "load"); + yield BrowserTestUtils.waitForEvent(win.gBrowser.tabs[0], "SSTabRestored"); + + is(win.gBrowser.tabs.length, 1, "Should have restored one tab"); + is(win.gBrowser.selectedBrowser.currentURI.spec, uri, "Should have restored the right page"); + + yield promiseWindowClosed(win); +});
--- a/browser/components/readinglist/ReadingList.jsm +++ b/browser/components/readinglist/ReadingList.jsm @@ -189,16 +189,18 @@ ReadingListImpl.prototype = { * is updated. Rejected with an Error on error. */ addItem: Task.async(function* (obj) { obj = stripNonItemProperties(obj); yield this._store.addItem(obj); this._invalidateIterators(); let item = this._itemFromObject(obj); this._callListeners("onItemAdded", item); + let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager); + mm.broadcastAsyncMessage("Reader:Added", item); return item; }), /** * Updates the properties of an item that belongs to the list. * * The passed-in item may have as few or as many properties that you want to * set; only the properties that are present are updated. The item must have @@ -229,20 +231,34 @@ ReadingListImpl.prototype = { * Error on error. */ deleteItem: Task.async(function* (item) { this._ensureItemBelongsToList(item); yield this._store.deleteItemByURL(item.url); item.list = null; this._itemsByURL.delete(item.url); this._invalidateIterators(); + let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager); + mm.broadcastAsyncMessage("Reader:Removed", item); this._callListeners("onItemDeleted", item); }), /** + * Find any item that matches a given URL - either the item's URL, or its + * resolved URL. + * + * @param {String/nsIURI} uri - URI to match against. This will be normalized. + */ + getItemForURL: Task.async(function* (uri) { + let url = this._normalizeURI(uri).spec; + let [item] = yield this.iterator({url: url}, {resolvedURL: url}).items(1); + return item; + }), + + /** * Adds a listener that will be notified when the list changes. Listeners * are objects with the following optional methods: * * onItemAdded(item) * onItemUpdated(item) * onItemDeleted(item) * * @param listener A listener object. @@ -284,16 +300,32 @@ ReadingListImpl.prototype = { // A Set containing nsIWeakReferences that refer to valid iterators produced // by the list. _iterators: null, // A Set containing listener objects. _listeners: null, /** + * Normalize a URI, stripping away extraneous parts we don't want to store + * or compare against. + * + * @param {nsIURI/String} uri - URI to normalize. + * @returns {nsIURI} Cloned and normalized version of the input URI. + */ + _normalizeURI(uri) { + if (typeof uri == "string") { + uri = Services.io.newURI(uri, "", null); + } + uri = uri.cloneIgnoringRef(); + uri.userPass = ""; + return uri; + }, + + /** * Returns the ReadingListItem represented by the given simple object. If * the item doesn't exist yet, it's created first. * * @param obj A simple object with item properties. * @return The ReadingListItem. */ _itemFromObject(obj) { let itemWeakRef = this._itemsByURL.get(obj.url); @@ -344,26 +376,40 @@ ReadingListImpl.prototype = { _ensureItemBelongsToList(item) { if (item.list != this) { throw new Error("The item does not belong to this list"); } }, }; +let _unserializable = () => {}; // See comments in the ReadingListItem ctor. + /** * An item in a reading list. * * Each item belongs to a list, and it's an error to use an item with a * ReadingList that the item doesn't belong to. * * @param props The properties of the item, as few or many as you want. */ function ReadingListItem(props={}) { this._properties = {}; + + // |this._unserializable| works around a problem when sending one of these + // items via a message manager. If |this.list| is set, the item can't be + // transferred directly, so .toJSON is implicitly called and the object + // returned via that is sent. However, once the item is deleted and |this.list| + // is null, the item *can* be directly serialized - so the message handler + // sees the "raw" object - ie, it sees "_properties" etc. + // We work around this problem by *always* having an unserializable property + // on the object - this way the implicit .toJSON call is always made, even + // when |this.list| is null. + this._unserializable = _unserializable; + this.setProperties(props, false); } ReadingListItem.prototype = { /** * Item's unique ID. * @type string @@ -825,17 +871,17 @@ function hash(str) { createInstance(Ci.nsICryptoHash); hasher.init(Ci.nsICryptoHash.MD5); let stream = Cc["@mozilla.org/io/string-input-stream;1"]. createInstance(Ci.nsIStringInputStream); stream.data = str; hasher.updateFromStream(stream, -1); let binaryStr = hasher.finish(false); let hexStr = - [("0" + binaryStr.charCodeAt(i).toString(16)).slice(-2) for (i in hash)]. + [("0" + binaryStr.charCodeAt(i).toString(16)).slice(-2) for (i in binaryStr)]. join(""); return hexStr; } function clone(obj) { return Cu.cloneInto(obj, {}, { cloneFunctions: false }); }
--- a/browser/components/readinglist/test/xpcshell/test_ReadingList.js +++ b/browser/components/readinglist/test/xpcshell/test_ReadingList.js @@ -692,12 +692,12 @@ function hash(str) { createInstance(Ci.nsICryptoHash); hasher.init(Ci.nsICryptoHash.MD5); let stream = Cc["@mozilla.org/io/string-input-stream;1"]. createInstance(Ci.nsIStringInputStream); stream.data = str; hasher.updateFromStream(stream, -1); let binaryStr = hasher.finish(false); let hexStr = - [("0" + binaryStr.charCodeAt(i).toString(16)).slice(-2) for (i in hash)]. + [("0" + binaryStr.charCodeAt(i).toString(16)).slice(-2) for (i in binaryStr)]. join(""); return hexStr; }
--- a/browser/devtools/debugger/test/browser_dbg_source-maps-01.js +++ b/browser/devtools/debugger/test/browser_dbg_source-maps-01.js @@ -74,25 +74,25 @@ function testSetBreakpoint() { return deferred.promise; } function testSetBreakpointBlankLine() { let deferred = promise.defer(); let sourceForm = getSourceForm(gSources, COFFEE_URL); let source = gDebugger.gThreadClient.source(sourceForm); - source.setBreakpoint({ line: 3 }, aResponse => { + source.setBreakpoint({ line: 7 }, aResponse => { ok(!aResponse.error, "Should be able to set a breakpoint in a coffee source file on a blank line."); ok(aResponse.actualLocation, "Because 3 is empty, we should have an actualLocation."); is(aResponse.actualLocation.source.url, COFFEE_URL, "actualLocation.actor should be source mapped to the coffee file."); - is(aResponse.actualLocation.line, 2, - "actualLocation.line should be source mapped back to 2."); + is(aResponse.actualLocation.line, 8, + "actualLocation.line should be source mapped back to 8."); deferred.resolve(); }); return deferred.promise; } function testHitBreakpoint() { @@ -142,17 +142,17 @@ function testStepping() { is(aPacket.why.type, "resumeLimit", "and the reason we should be paused is because we hit the resume limit."); // Check that we stopped at the right place, by making sure that the // environment is in the state that we expect. is(aPacket.frame.environment.bindings.variables.start.value, 0, "'start' is 0."); is(aPacket.frame.environment.bindings.variables.stop.value, 5, - "'stop' hasn't been assigned to yet."); + "'stop' is 5."); is(aPacket.frame.environment.bindings.variables.pivot.value.type, "undefined", "'pivot' hasn't been assigned to yet."); waitForCaretUpdated(gPanel, 6).then(deferred.resolve); }); }); return deferred.promise;
--- a/browser/devtools/debugger/test/browser_dbg_source-maps-03.js +++ b/browser/devtools/debugger/test/browser_dbg_source-maps-03.js @@ -40,21 +40,21 @@ function checkInitialSource() { "not the whitespace stripped minified version."); } function testSetBreakpoint() { let deferred = promise.defer(); let sourceForm = getSourceForm(gSources, JS_URL); let source = gDebugger.gThreadClient.source(sourceForm); - source.setBreakpoint({ line: 30, column: 21 }, aResponse => { + source.setBreakpoint({ line: 30 }, aResponse => { ok(!aResponse.error, "Should be able to set a breakpoint in a js file."); ok(!aResponse.actualLocation, - "Should be able to set a breakpoint on line 30 and column 10."); + "Should be able to set a breakpoint on line 30."); gDebugger.gClient.addOneTimeListener("resumed", () => { waitForCaretAndScopes(gPanel, 30).then(() => { // Make sure that we have the right stack frames. is(gFrames.itemCount, 9, "Should have nine frames."); is(gFrames.getItemAtIndex(0).attachment.url.indexOf(".min.js"), -1, "First frame should not be a minified JS frame.");
--- a/browser/devtools/performance/modules/front.js +++ b/browser/devtools/performance/modules/front.js @@ -347,30 +347,32 @@ PerformanceFront.prototype = { return memoryEndTime; }), /** * At regular intervals, pull allocations from the memory actor, and forward * them to consumers. */ _pullAllocationSites: Task.async(function *() { + let isDetached = (yield this._request("memory", "getState")) !== "attached"; + if (isDetached) { + return; + } + let memoryData = yield this._request("memory", "getAllocations"); - let isStillAttached = yield this._request("memory", "getState") == "attached"; this.emit("allocations", { sites: memoryData.allocations, timestamps: memoryData.allocationsTimestamps, frames: memoryData.frames, counts: memoryData.counts }); - if (isStillAttached) { - let delay = DEFAULT_ALLOCATION_SITES_PULL_TIMEOUT; - this._sitesPullTimeout = setTimeout(this._pullAllocationSites, delay); - } + let delay = DEFAULT_ALLOCATION_SITES_PULL_TIMEOUT; + this._sitesPullTimeout = setTimeout(this._pullAllocationSites, delay); }), /** * Overrides the options sent to the built-in profiler module when activating, * such as the maximum entries count, the sampling interval etc. * * Used in tests and for older backend implementations. */
--- a/browser/devtools/performance/performance.xul +++ b/browser/devtools/performance/performance.xul @@ -193,44 +193,24 @@ </vbox> <hbox id="js-flamegraph-view" flex="1"> </hbox> <vbox id="memory-calltree-view" flex="1"> <hbox class="call-tree-headers-container"> <label class="plain call-tree-header" - type="duration" - crop="end" - value="&profilerUI.table.totalDuration2;"/> - <label class="plain call-tree-header" - type="percentage" - crop="end" - value="&profilerUI.table.totalPercentage;"/> - <label class="plain call-tree-header" type="allocations" crop="end" value="&profilerUI.table.totalAlloc;"/> <label class="plain call-tree-header" - type="self-duration" - crop="end" - value="&profilerUI.table.selfDuration2;"/> - <label class="plain call-tree-header" - type="self-percentage" - crop="end" - value="&profilerUI.table.selfPercentage;"/> - <label class="plain call-tree-header" type="self-allocations" crop="end" value="&profilerUI.table.selfAlloc;"/> <label class="plain call-tree-header" - type="samples" - crop="end" - value="&profilerUI.table.samples;"/> - <label class="plain call-tree-header" type="function" crop="end" value="&profilerUI.table.function;"/> </hbox> <vbox class="call-tree-cells-container" flex="1"/> </vbox> <hbox id="memory-flamegraph-view" flex="1">
--- a/browser/devtools/performance/test/browser.ini +++ b/browser/devtools/performance/test/browser.ini @@ -11,16 +11,18 @@ support-files = [browser_perf-aaa-run-first-leaktest.js] [browser_perf-allocations-to-samples.js] [browser_perf-compatibility-01.js] [browser_perf-compatibility-02.js] [browser_perf-compatibility-03.js] [browser_perf-compatibility-04.js] [browser_perf-clear-01.js] [browser_perf-clear-02.js] +[browser_perf-columns-js-calltree.js] +[browser_perf-columns-memory-calltree.js] [browser_perf-data-massaging-01.js] [browser_perf-data-samples.js] [browser_perf-details-calltree-render.js] [browser_perf-details-flamegraph-render.js] [browser_perf-details-memory-calltree-render.js] [browser_perf-details-memory-flamegraph-render.js] [browser_perf-details-waterfall-render.js] [browser_perf-details-01.js]
new file mode 100644 --- /dev/null +++ b/browser/devtools/performance/test/browser_perf-columns-js-calltree.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that the js call tree view renders the correct columns. + */ +function spawnTest () { + let { panel } = yield initPerformance(SIMPLE_URL); + let { EVENTS, $, $$, DetailsView, JsCallTreeView } = panel.panelWin; + + // Enable platform data to show the `busyWait` function in the tree. + Services.prefs.setBoolPref(PLATFORM_DATA_PREF, true); + + yield DetailsView.selectView("js-calltree"); + ok(DetailsView.isViewSelected(JsCallTreeView), "The call tree is now selected."); + + yield startRecording(panel); + yield busyWait(1000); + + let rendered = once(JsCallTreeView, EVENTS.JS_CALL_TREE_RENDERED); + yield stopRecording(panel); + yield rendered; + + testCells($, $$, { + "duration": true, + "percentage": true, + "allocations": false, + "self-duration": true, + "self-percentage": true, + "self-allocations": false, + "samples": true, + "function": true + }); + + yield teardown(panel); + finish(); +} + +function testCells($, $$, visibleCells) { + for (let cell in visibleCells) { + if (visibleCells[cell]) { + ok($(`.call-tree-cell[type=${cell}]`), + `At least one ${cell} column was visible in the tree.`); + } else { + ok(!$(`.call-tree-cell[type=${cell}]`), + `No ${cell} columns were visible in the tree.`); + } + } + + is($$(".call-tree-cell", $(".call-tree-item")).length, + Object.keys(visibleCells).filter(e => visibleCells[e]).length, + "The correct number of cells were found in the tree."); +}
new file mode 100644 --- /dev/null +++ b/browser/devtools/performance/test/browser_perf-columns-memory-calltree.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that the memory call tree view renders the correct columns. + */ +function spawnTest () { + let { panel } = yield initPerformance(SIMPLE_URL); + let { EVENTS, $, $$, DetailsView, MemoryCallTreeView } = panel.panelWin; + + // Enable memory to test. + Services.prefs.setBoolPref(MEMORY_PREF, true); + + yield DetailsView.selectView("memory-calltree"); + ok(DetailsView.isViewSelected(MemoryCallTreeView), "The call tree is now selected."); + + yield startRecording(panel); + yield busyWait(1000); + + let rendered = once(MemoryCallTreeView, EVENTS.MEMORY_CALL_TREE_RENDERED); + yield stopRecording(panel); + yield rendered; + + testCells($, $$, { + "duration": false, + "percentage": false, + "allocations": true, + "self-duration": false, + "self-percentage": false, + "self-allocations": true, + "samples": false, + "function": true + }); + + yield teardown(panel); + finish(); +} + +function testCells($, $$, visibleCells) { + for (let cell in visibleCells) { + if (visibleCells[cell]) { + ok($(`.call-tree-cell[type=${cell}]`), + `At least one ${cell} column was visible in the tree.`); + } else { + ok(!$(`.call-tree-cell[type=${cell}]`), + `No ${cell} columns were visible in the tree.`); + } + } + + is($$(".call-tree-cell", $(".call-tree-item")).length, + Object.keys(visibleCells).filter(e => visibleCells[e]).length, + "The correct number of cells were found in the tree."); +}
--- a/browser/devtools/performance/test/browser_perf-front.js +++ b/browser/devtools/performance/test/browser_perf-front.js @@ -5,50 +5,65 @@ * Test basic functionality of PerformanceFront, emitting start and endtime values */ let WAIT_TIME = 1000; function spawnTest () { let { target, front } = yield initBackend(SIMPLE_URL); + let count = 0; + let counter = () => count++; + let { profilerStartTime, timelineStartTime, memoryStartTime } = yield front.startRecording({ withAllocations: true }); ok(typeof profilerStartTime === "number", "The front.startRecording() emits a profiler start time."); ok(typeof timelineStartTime === "number", "The front.startRecording() emits a timeline start time."); ok(typeof memoryStartTime === "number", "The front.startRecording() emits a memory start time."); - yield busyWait(WAIT_TIME); + // Record allocation events to ensure it's called more than once + // so we know it's polling + front.on("allocations", counter); + + yield Promise.all([ + busyWait(WAIT_TIME), + waitUntil(() => count > 1) + ]); let { profilerEndTime, timelineEndTime, memoryEndTime } = yield front.stopRecording({ withAllocations: true }); + front.off("allocations", counter); + ok(typeof profilerEndTime === "number", "The front.stopRecording() emits a profiler end time."); ok(typeof timelineEndTime === "number", "The front.stopRecording() emits a timeline end time."); ok(typeof memoryEndTime === "number", "The front.stopRecording() emits a memory end time."); ok(profilerEndTime > profilerStartTime, "The profilerEndTime is after profilerStartTime."); ok(timelineEndTime > timelineStartTime, "The timelineEndTime is after timelineStartTime."); ok(memoryEndTime > memoryStartTime, "The memoryEndTime is after memoryStartTime."); + is((yield front._request("memory", "getState")), "detached", + "memory actor is detached when stopping recording with allocations"); + yield removeTab(target.tab); finish(); }
--- a/browser/devtools/performance/test/browser_profiler_tree-view-01.js +++ b/browser/devtools/performance/test/browser_profiler_tree-view-01.js @@ -17,59 +17,49 @@ function test() { treeRoot.autoExpandDepth = 0; treeRoot.attachTo(container); is(container.childNodes.length, 1, "The container node should have one child available."); is(container.childNodes[0].className, "call-tree-item", "The root node in the tree has the correct class name."); - is(container.childNodes[0].childNodes.length, 8, + is(container.childNodes[0].childNodes.length, 6, "The root node in the tree has the correct number of children."); - is(container.childNodes[0].querySelectorAll(".call-tree-cell").length, 8, - "The root node in the tree has only 'call-tree-cell' children."); + is(container.childNodes[0].querySelectorAll(".call-tree-cell").length, 6, + "The root node in the tree has only 6 'call-tree-cell' children."); is(container.childNodes[0].childNodes[0].getAttribute("type"), "duration", "The root node in the tree has a duration cell."); is(container.childNodes[0].childNodes[0].getAttribute("value"), "15 ms", "The root node in the tree has the correct duration cell value."); is(container.childNodes[0].childNodes[1].getAttribute("type"), "percentage", "The root node in the tree has a percentage cell."); is(container.childNodes[0].childNodes[1].getAttribute("value"), "100%", "The root node in the tree has the correct percentage cell value."); - is(container.childNodes[0].childNodes[2].getAttribute("type"), "allocations", + is(container.childNodes[0].childNodes[2].getAttribute("type"), "self-duration", "The root node in the tree has a self-duration cell."); - is(container.childNodes[0].childNodes[2].getAttribute("value"), "0", - "The root node in the tree has the correct self-duration cell value."); - - is(container.childNodes[0].childNodes[3].getAttribute("type"), "self-duration", - "The root node in the tree has a self-duration cell."); - is(container.childNodes[0].childNodes[3].getAttribute("value"), "0 ms", + is(container.childNodes[0].childNodes[2].getAttribute("value"), "0 ms", "The root node in the tree has the correct self-duration cell value."); - is(container.childNodes[0].childNodes[4].getAttribute("type"), "self-percentage", + is(container.childNodes[0].childNodes[3].getAttribute("type"), "self-percentage", "The root node in the tree has a self-percentage cell."); - is(container.childNodes[0].childNodes[4].getAttribute("value"), "0%", + is(container.childNodes[0].childNodes[3].getAttribute("value"), "0%", "The root node in the tree has the correct self-percentage cell value."); - is(container.childNodes[0].childNodes[5].getAttribute("type"), "self-allocations", - "The root node in the tree has a self-percentage cell."); - is(container.childNodes[0].childNodes[5].getAttribute("value"), "0", - "The root node in the tree has the correct self-percentage cell value."); - - is(container.childNodes[0].childNodes[6].getAttribute("type"), "samples", + is(container.childNodes[0].childNodes[4].getAttribute("type"), "samples", "The root node in the tree has an samples cell."); - is(container.childNodes[0].childNodes[6].getAttribute("value"), "4", + is(container.childNodes[0].childNodes[4].getAttribute("value"), "4", "The root node in the tree has the correct samples cell value."); - is(container.childNodes[0].childNodes[7].getAttribute("type"), "function", + is(container.childNodes[0].childNodes[5].getAttribute("type"), "function", "The root node in the tree has a function cell."); - is(container.childNodes[0].childNodes[7].style.MozMarginStart, "0px", + is(container.childNodes[0].childNodes[5].style.MozMarginStart, "0px", "The root node in the tree has the correct indentation."); finish(); } let gSamples = [{ time: 5, frames: [
--- a/browser/devtools/performance/test/browser_profiler_tree-view-04.js +++ b/browser/devtools/performance/test/browser_profiler_tree-view-04.js @@ -39,36 +39,32 @@ function test() { "The .A.B.D node's 'category' attribute is correct."); is(D.target.getAttribute("tooltiptext"), "D (http://foo/bar/baz:78)", "The .A.B.D node's 'tooltiptext' attribute is correct."); ok(!A.target.querySelector(".call-tree-zoom").hidden, "The .A.B.D node's zoom button cell should not be hidden."); ok(!A.target.querySelector(".call-tree-category").hidden, "The .A.B.D node's category label cell should not be hidden."); - is(D.target.childNodes.length, 8, + is(D.target.childNodes.length, 6, "The number of columns displayed for tree items is correct."); is(D.target.childNodes[0].getAttribute("type"), "duration", "The first column displayed for tree items is correct."); is(D.target.childNodes[1].getAttribute("type"), "percentage", "The third column displayed for tree items is correct."); - is(D.target.childNodes[2].getAttribute("type"), "allocations", + is(D.target.childNodes[2].getAttribute("type"), "self-duration", "The second column displayed for tree items is correct."); - is(D.target.childNodes[3].getAttribute("type"), "self-duration", - "The second column displayed for tree items is correct."); - is(D.target.childNodes[4].getAttribute("type"), "self-percentage", + is(D.target.childNodes[3].getAttribute("type"), "self-percentage", "The fourth column displayed for tree items is correct."); - is(D.target.childNodes[5].getAttribute("type"), "self-allocations", - "The fourth column displayed for tree items is correct."); - is(D.target.childNodes[6].getAttribute("type"), "samples", + is(D.target.childNodes[4].getAttribute("type"), "samples", "The fifth column displayed for tree items is correct."); - is(D.target.childNodes[7].getAttribute("type"), "function", + is(D.target.childNodes[5].getAttribute("type"), "function", "The sixth column displayed for tree items is correct."); - let functionCell = D.target.childNodes[7]; + let functionCell = D.target.childNodes[5]; is(functionCell.childNodes.length, 9, "The number of columns displayed for function cells is correct."); is(functionCell.childNodes[0].className, "arrow theme-twisty", "The first node displayed for function cells is correct."); is(functionCell.childNodes[1].className, "plain call-tree-name", "The second node displayed for function cells is correct."); is(functionCell.childNodes[2].className, "plain call-tree-url",
--- a/browser/devtools/performance/views/details-js-call-tree.js +++ b/browser/devtools/performance/views/details-js-call-tree.js @@ -98,19 +98,16 @@ let JsCallTreeView = Heritage.extend(Det // Pipe "focus" events to the view, mostly for tests root.on("focus", () => this.emit("focus")); // Clear out other call trees. let container = $("#js-calltree-view > .call-tree-cells-container"); container.innerHTML = ""; root.attachTo(container); - // Profiler data does not contain memory allocations information. - root.toggleAllocations(false); - // When platform data isn't shown, hide the cateogry labels, since they're // only available for C++ frames. let contentOnly = !PerformanceController.getOption("show-platform-data"); root.toggleCategories(!contentOnly); }, toString: () => "[object JsCallTreeView]" });
--- a/browser/devtools/performance/views/details-memory-call-tree.js +++ b/browser/devtools/performance/views/details-memory-call-tree.js @@ -84,16 +84,23 @@ let MemoryCallTreeView = Heritage.extend inverted: options.inverted, // Root nodes are hidden in inverted call trees. hidden: options.inverted, // Memory call trees should be sorted by allocations. sortingPredicate: (a, b) => a.frame.allocations < b.frame.allocations ? 1 : -1, // Call trees should only auto-expand when not inverted. Passing undefined // will default to the CALL_TREE_AUTO_EXPAND depth. autoExpandDepth: options.inverted ? 0 : undefined, + // Some cells like the time duration and cost percentage don't make sense + // for a memory allocations call tree. + visibleCells: { + allocations: true, + selfAllocations: true, + function: true + } }); // Bind events. root.on("link", this._onLink); // Pipe "focus" events to the view, mostly for tests root.on("focus", () => this.emit("focus"));
--- a/browser/devtools/profiler/ui-profile.js +++ b/browser/devtools/profiler/ui-profile.js @@ -504,17 +504,16 @@ let ProfileView = { callTreeRoot.attachTo($(".call-tree-cells-container", panel)); if (!options.skipCallTreeFocus) { callTreeRoot.focus(); } let contentOnly = !Prefs.showPlatformData; callTreeRoot.toggleCategories(!contentOnly); - callTreeRoot.toggleAllocations(false); this._callTreeRootByPanel.set(panel, callTreeRoot); }, /** * Shortcuts for accessing the recording info or widgets for a <panel>. * @param nsIDOMNode panel [optional] * @return object
--- a/browser/devtools/shared/profiler/tree-view.js +++ b/browser/devtools/shared/profiler/tree-view.js @@ -12,19 +12,30 @@ loader.lazyImporter(this, "Heritage", "resource:///modules/devtools/ViewHelpers.jsm"); loader.lazyImporter(this, "AbstractTreeItem", "resource:///modules/devtools/AbstractTreeItem.jsm"); const MILLISECOND_UNITS = L10N.getStr("table.ms"); const PERCENTAGE_UNITS = L10N.getStr("table.percentage"); const URL_LABEL_TOOLTIP = L10N.getStr("table.url.tooltiptext"); const ZOOM_BUTTON_TOOLTIP = L10N.getStr("table.zoom.tooltiptext"); -const CALL_TREE_AUTO_EXPAND = 3; // depth const CALL_TREE_INDENTATION = 16; // px + const DEFAULT_SORTING_PREDICATE = (a, b) => a.frame.samples < b.frame.samples ? 1 : -1; +const DEFAULT_AUTO_EXPAND_DEPTH = 3; // depth +const DEFAULT_VISIBLE_CELLS = { + duration: true, + percentage: true, + allocations: false, + selfDuration: true, + selfPercentage: true, + selfAllocations: false, + samples: true, + function: true +}; const clamp = (val, min, max) => Math.max(min, Math.min(max, val)); const sum = vals => vals.reduce((a, b) => a + b, 0); exports.CallView = CallView; /** * An item in a call tree view, which looks like this: @@ -50,43 +61,50 @@ exports.CallView = CallView; * @param boolean hidden [optional] * Whether this node should be hidden and not contribute to depth/level * calculations. Defaults to false. * @param boolean inverted [optional] * Whether the call tree has been inverted (bottom up, rather than * top-down). Defaults to false. * @param function sortingPredicate [optional] * The predicate used to sort the tree items when created. Defaults to - * the caller's sortingPredicate if a caller exists, otherwise defaults + * the caller's `sortingPredicate` if a caller exists, otherwise defaults * to DEFAULT_SORTING_PREDICATE. The two passed arguments are FrameNodes. * @param number autoExpandDepth [optional] * The depth to which the tree should automatically expand. Defualts to * the caller's `autoExpandDepth` if a caller exists, otherwise defaults - * to CALL_TREE_AUTO_EXPAND. + * to DEFAULT_AUTO_EXPAND_DEPTH. + * @param object visibleCells + * An object specifying which cells are visible in the tree. Defaults to + * the caller's `visibleCells` if a caller exists, otherwise defaults + * to DEFAULT_VISIBLE_CELLS. */ -function CallView({ caller, frame, level, hidden, inverted, sortingPredicate, autoExpandDepth }) { - // Assume no indentation if this tree item's level is not specified. - level = level || 0; - - // Don't increase indentation if this tree item is hidden. - if (hidden) { - level--; - } - - AbstractTreeItem.call(this, { parent: caller, level }); +function CallView({ + caller, frame, level, hidden, inverted, + sortingPredicate, autoExpandDepth, visibleCells +}) { + AbstractTreeItem.call(this, { + parent: caller, + level: level|0 - (hidden ? 1 : 0) + }); this.sortingPredicate = sortingPredicate != null ? sortingPredicate : caller ? caller.sortingPredicate : DEFAULT_SORTING_PREDICATE this.autoExpandDepth = autoExpandDepth != null ? autoExpandDepth : caller ? caller.autoExpandDepth - : CALL_TREE_AUTO_EXPAND; + : DEFAULT_AUTO_EXPAND_DEPTH; + + this.visibleCells = visibleCells != null + ? visibleCells + : caller ? caller.visibleCells + : Object.create(DEFAULT_VISIBLE_CELLS); this.caller = caller; this.frame = frame; this.hidden = hidden; this.inverted = inverted; this._onUrlClick = this._onUrlClick.bind(this); this._onZoomClick = this._onZoomClick.bind(this); @@ -105,69 +123,110 @@ CallView.prototype = Heritage.extend(Abs let frameInfo = this.frame.getInfo(); let framePercentage = this._getPercentage(this.frame.samples); let selfPercentage; let selfDuration; let totalAllocations; if (!this._getChildCalls().length) { - selfPercentage = framePercentage; - selfDuration = this.frame.duration; - totalAllocations = this.frame.allocations; + if (this.visibleCells.selfPercentage) { + selfPercentage = framePercentage; + } + if (this.visibleCells.selfDuration) { + selfDuration = this.frame.duration; + } + if (this.visibleCells.allocations) { + totalAllocations = this.frame.allocations; + } } else { - let childrenPercentage = sum( - [this._getPercentage(c.samples) for (c of this._getChildCalls())]); - let childrenDuration = sum( - [c.duration for (c of this._getChildCalls())]); - let childrenAllocations = sum( - [c.allocations for (c of this._getChildCalls())]); - - selfPercentage = clamp(framePercentage - childrenPercentage, 0, 100); - selfDuration = this.frame.duration - childrenDuration; - totalAllocations = this.frame.allocations + childrenAllocations; - + // Avoid performing costly computations if the respective columns + // won't be shown anyway. + if (this.visibleCells.selfPercentage) { + let childrenPercentage = sum([this._getPercentage(c.samples) for (c of this._getChildCalls())]); + selfPercentage = clamp(framePercentage - childrenPercentage, 0, 100); + } + if (this.visibleCells.selfDuration) { + let childrenDuration = sum([c.duration for (c of this._getChildCalls())]); + selfDuration = this.frame.duration - childrenDuration; + } + if (this.visibleCells.allocations) { + let childrenAllocations = sum([c.allocations for (c of this._getChildCalls())]); + totalAllocations = this.frame.allocations + childrenAllocations; + } if (this.inverted) { selfPercentage = framePercentage - selfPercentage; selfDuration = this.frame.duration - selfDuration; } } - let durationCell = this._createTimeCell(this.frame.duration); - let selfDurationCell = this._createTimeCell(selfDuration, true); - let percentageCell = this._createExecutionCell(framePercentage); - let selfPercentageCell = this._createExecutionCell(selfPercentage, true); - let allocationsCell = this._createAllocationsCell(totalAllocations); - let selfAllocationsCell = this._createAllocationsCell(this.frame.allocations, true); - let samplesCell = this._createSamplesCell(this.frame.samples); - let functionCell = this._createFunctionCell(arrowNode, frameInfo, this.level); + if (this.visibleCells.duration) { + var durationCell = this._createTimeCell(this.frame.duration); + } + if (this.visibleCells.selfDuration) { + var selfDurationCell = this._createTimeCell(selfDuration, true); + } + if (this.visibleCells.percentage) { + var percentageCell = this._createExecutionCell(framePercentage); + } + if (this.visibleCells.selfPercentage) { + var selfPercentageCell = this._createExecutionCell(selfPercentage, true); + } + if (this.visibleCells.allocations) { + var allocationsCell = this._createAllocationsCell(totalAllocations); + } + if (this.visibleCells.selfAllocations) { + var selfAllocationsCell = this._createAllocationsCell(this.frame.allocations, true); + } + if (this.visibleCells.samples) { + var samplesCell = this._createSamplesCell(this.frame.samples); + } + if (this.visibleCells.function) { + var functionCell = this._createFunctionCell(arrowNode, frameInfo, this.level); + } let targetNode = document.createElement("hbox"); targetNode.className = "call-tree-item"; targetNode.setAttribute("origin", frameInfo.isContent ? "content" : "chrome"); targetNode.setAttribute("category", frameInfo.categoryData.abbrev || ""); targetNode.setAttribute("tooltiptext", this.frame.location || ""); if (this.hidden) { targetNode.style.display = "none"; } let isRoot = frameInfo.nodeType == "Thread"; if (isRoot) { functionCell.querySelector(".call-tree-zoom").hidden = true; functionCell.querySelector(".call-tree-category").hidden = true; } - targetNode.appendChild(durationCell); - targetNode.appendChild(percentageCell); - targetNode.appendChild(allocationsCell); - targetNode.appendChild(selfDurationCell); - targetNode.appendChild(selfPercentageCell); - targetNode.appendChild(selfAllocationsCell); - targetNode.appendChild(samplesCell); - targetNode.appendChild(functionCell); + if (this.visibleCells.duration) { + targetNode.appendChild(durationCell); + } + if (this.visibleCells.percentage) { + targetNode.appendChild(percentageCell); + } + if (this.visibleCells.allocations) { + targetNode.appendChild(allocationsCell); + } + if (this.visibleCells.selfDuration) { + targetNode.appendChild(selfDurationCell); + } + if (this.visibleCells.selfPercentage) { + targetNode.appendChild(selfPercentageCell); + } + if (this.visibleCells.selfAllocations) { + targetNode.appendChild(selfAllocationsCell); + } + if (this.visibleCells.samples) { + targetNode.appendChild(samplesCell); + } + if (this.visibleCells.function) { + targetNode.appendChild(functionCell); + } return targetNode; }, /** * Calculate what percentage of all samples the given number of samples is. */ _getPercentage: function(samples) { @@ -297,28 +356,16 @@ CallView.prototype = Heritage.extend(Abs if (hasDescendants == false) { arrowNode.setAttribute("invisible", ""); } return cell; }, /** - * Toggles the allocations information hidden or visible. - * @param boolean visible - */ - toggleAllocations: function(visible) { - if (!visible) { - this.container.setAttribute("allocations-hidden", ""); - } else { - this.container.removeAttribute("allocations-hidden"); - } - }, - - /** * Toggles the category information hidden or visible. * @param boolean visible */ toggleCategories: function(visible) { if (!visible) { this.container.setAttribute("categories-hidden", ""); } else { this.container.removeAttribute("categories-hidden");
--- a/browser/modules/ReaderParent.jsm +++ b/browser/modules/ReaderParent.jsm @@ -9,16 +9,17 @@ const { classes: Cc, interfaces: Ci, uti this.EXPORTED_SYMBOLS = [ "ReaderParent" ]; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/Task.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "ReaderMode", "resource://gre/modules/ReaderMode.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ReadingList", "resource:///modules/readinglist/ReadingList.jsm"); const gStringBundle = Services.strings.createBundle("chrome://global/locale/aboutReader.properties"); let ReaderParent = { MESSAGES: [ "Reader:AddToList", "Reader:ArticleGet", @@ -37,38 +38,48 @@ let ReaderParent = { for (let msg of this.MESSAGES) { mm.addMessageListener(msg, this); } }, receiveMessage: function(message) { switch (message.name) { case "Reader:AddToList": - // XXX: To implement. + ReadingList.addItem(message.data.article); break; case "Reader:ArticleGet": this._getArticle(message.data.url, message.target).then((article) => { // Make sure the target browser is still alive before trying to send data back. if (message.target.messageManager) { message.target.messageManager.sendAsyncMessage("Reader:ArticleData", { article: article }); } }); break; case "Reader:FaviconRequest": { // XXX: To implement. break; } case "Reader:ListStatusRequest": - // XXX: To implement. + ReadingList.count(message.data).then(count => { + let mm = message.target.messageManager + // Make sure the target browser is still alive before trying to send data back. + if (mm) { + mm.sendAsyncMessage("Reader:ListStatusData", + { inReadingList: !!count, url: message.data.url }); + } + }); break; case "Reader:RemoveFromList": - // XXX: To implement. + // We need to get the "real" item to delete it. + ReadingList.getItemForURL(message.data.url).then(item => { + ReadingList.deleteItem(item) + }); break; case "Reader:Share": // XXX: To implement. break; case "Reader:SystemUIVisibility": // XXX: To implement.
--- a/browser/themes/shared/devtools/performance.inc.css +++ b/browser/themes/shared/devtools/performance.inc.css @@ -119,18 +119,16 @@ /* Profile call tree */ .call-tree-cells-container { /* Hack: force hardware acceleration */ transform: translateZ(1px); overflow: auto; } -.call-tree-cells-container[allocations-hidden] .call-tree-cell[type="allocations"], -.call-tree-cells-container[allocations-hidden] .call-tree-cell[type="self-allocations"], .call-tree-cells-container[categories-hidden] .call-tree-category { display: none; } .call-tree-header { font-size: 90%; padding-top: 2px !important; padding-bottom: 2px !important;
--- a/browser/themes/shared/devtools/profiler.inc.css +++ b/browser/themes/shared/devtools/profiler.inc.css @@ -215,18 +215,16 @@ } .call-tree-cells-container { /* Hack: force hardware acceleration */ transform: translateZ(1px); overflow: auto; } -.call-tree-cells-container[allocations-hidden] .call-tree-cell[type="allocations"], -.call-tree-cells-container[allocations-hidden] .call-tree-cell[type="self-allocations"], .call-tree-cells-container[categories-hidden] .call-tree-category { display: none; } .call-tree-header[type="duration"], .call-tree-cell[type="duration"], .call-tree-header[type="self-duration"], .call-tree-cell[type="self-duration"] {
--- a/mobile/android/base/android-services.mozbuild +++ b/mobile/android/base/android-services.mozbuild @@ -1156,12 +1156,32 @@ sync_java_files = [ 'sync/UnknownSynchronizerConfigurationVersionException.java', 'sync/Utils.java', 'tokenserver/TokenServerClient.java', 'tokenserver/TokenServerClientDelegate.java', 'tokenserver/TokenServerException.java', 'tokenserver/TokenServerToken.java', ] reading_list_service_java_files = [ + 'reading/ClientMetadata.java', + 'reading/ClientReadingListRecord.java', + 'reading/FetchSpec.java', + 'reading/LocalReadingListStorage.java', + 'reading/ReadingListChangeAccumulator.java', + 'reading/ReadingListClient.java', + 'reading/ReadingListClientContentValuesFactory.java', + 'reading/ReadingListClientRecordFactory.java', 'reading/ReadingListConstants.java', + 'reading/ReadingListDeleteDelegate.java', + 'reading/ReadingListRecord.java', + 'reading/ReadingListRecordDelegate.java', + 'reading/ReadingListRecordResponse.java', + 'reading/ReadingListRecordUploadDelegate.java', + 'reading/ReadingListResponse.java', + 'reading/ReadingListStorage.java', + 'reading/ReadingListStorageResponse.java', 'reading/ReadingListSyncAdapter.java', + 'reading/ReadingListSynchronizer.java', + 'reading/ReadingListSynchronizerDelegate.java', 'reading/ReadingListSyncService.java', + 'reading/ReadingListWipeDelegate.java', + 'reading/ServerReadingListRecord.java', ]
--- a/mobile/android/base/fxa/authenticator/AndroidFxAccount.java +++ b/mobile/android/base/fxa/authenticator/AndroidFxAccount.java @@ -535,16 +535,17 @@ public class AndroidFxAccount { if (stateLabelString == null || stateString == null) { throw new IllegalStateException("stateLabelString and stateString must not be null, but: " + "(stateLabelString == null) = " + (stateLabelString == null) + " and (stateString == null) = " + (stateString == null)); } try { StateLabel stateLabel = StateLabel.valueOf(stateLabelString); + Logger.debug(LOG_TAG, "Account is in state " + stateLabel); return StateFactory.fromJSONObject(stateLabel, new ExtendedJSONObject(stateString)); } catch (Exception e) { throw new IllegalStateException("could not get state", e); } } /** * <b>For debugging only!</b>
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ClientMetadata.java @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +public class ClientMetadata { + public final long id; // A client numeric ID. We don't always have a GUID. + public final long lastModified; // A client timestamp. + public final boolean isDeleted; + public final boolean isArchived; + + public ClientMetadata(final long id, final long lastModified, final boolean isDeleted, final boolean isArchived) { + this.id = id; + this.lastModified = lastModified; + this.isDeleted = isDeleted; + this.isArchived = isArchived; + } +}
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ClientReadingListRecord.java @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import org.mozilla.gecko.sync.ExtendedJSONObject; + +public class ClientReadingListRecord extends ReadingListRecord { + final ExtendedJSONObject fields; + public ClientMetadata clientMetadata; + + private String getDefaultAddedBy() { + return "Test Device"; // TODO + } + + /** + * Provided `fields` are *not copied*. + */ + public ClientReadingListRecord(final ServerMetadata serverMetadata, final ClientMetadata clientMetadata, final ExtendedJSONObject fields) { + super(serverMetadata); + this.clientMetadata = clientMetadata == null ? new ClientMetadata(-1L, -1L, false, false) : clientMetadata; + this.fields = fields; + } + + public ClientReadingListRecord(String url, String title, String addedBy) { + this(url, title, addedBy, System.currentTimeMillis(), false, false); + } + + public ClientReadingListRecord(String url, String title, String addedBy, long lastModified, boolean isDeleted, boolean isArchived) { + super(null); + + // Required. + if (url == null) { + throw new IllegalArgumentException("url must be provided."); + } + + final ExtendedJSONObject f = new ExtendedJSONObject(); + f.put("url", url); + f.put("title", title == null ? "" : title); + f.put("added_by", addedBy == null ? getDefaultAddedBy() : addedBy); + + this.fields = f; + this.clientMetadata = new ClientMetadata(-1L, lastModified, isDeleted, isArchived); + } + + public ExtendedJSONObject toJSON() { + final ExtendedJSONObject object = this.fields.deepCopy(); + final String guid = getGUID(); + + if (guid != null) { + object.put("id", guid); + } + return object; + } + + @Override + public String getAddedBy() { + return this.fields.getString("added_by"); + } + + @Override + public String getURL() { + return this.fields.getString("url"); // TODO: resolved_url + } + + @Override + public String getTitle() { + return this.fields.getString("title"); // TODO: resolved_title + } + + /** + * Produce a record just like the server record, but with the + * appropriate additional metadata, such as the local numeric ID. + */ + public ClientReadingListRecord givenServerRecord(ServerReadingListRecord down) { + return new ClientReadingListRecord(down.serverMetadata, this.clientMetadata, down.fields); + } +} \ No newline at end of file
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/FetchSpec.java @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import java.net.URI; +import java.net.URISyntaxException; + +import ch.boye.httpclientandroidlib.client.utils.URIBuilder; + +/** + * Defines the parameters that can be added to a reading list fetch URI. + */ +public class FetchSpec { + private final String queryString; + + private FetchSpec(final String q) { + this.queryString = q; + } + + public URI getURI(final URI serviceURI) throws URISyntaxException { + return new URIBuilder(serviceURI).setCustomQuery(queryString).build(); + } + + public URI getURI(final URI serviceURI, final String path) throws URISyntaxException { + final String currentPath = serviceURI.getPath(); + final String newPath = (currentPath == null ? "" : currentPath) + path; + return new URIBuilder(serviceURI).setPath(newPath) + .setCustomQuery(queryString) + .build(); + } + + public static class Builder { + final StringBuilder b = new StringBuilder(); + boolean first = true; + + public FetchSpec build() { + return new FetchSpec(b.toString()); + } + + private void ampersand() { + if (first) { + first = false; + return; + } + b.append('&'); + } + + public Builder setUnread(boolean unread) { + ampersand(); + b.append("unread="); + b.append(unread); + return this; + } + + private void qualifyAttribute(String qual, String attr) { + ampersand(); + b.append(qual); + b.append(attr); + b.append('='); + } + + public Builder setMinAttribute(String attr, int val) { + qualifyAttribute("min_", attr); + b.append(val); + return this; + } + + public Builder setMaxAttribute(String attr, int val) { + qualifyAttribute("max_", attr); + b.append(val); + return this; + } + + public Builder setNotAttribute(String attr, String val) { + qualifyAttribute("not_", attr); + b.append(val); + return this; + } + + public Builder setSince(long since) { + if (since == -1L) { + return this; + } + + ampersand(); + b.append("_since="); + b.append(since); + return this; + } + + public Builder setExcludeDeleted() { + ampersand(); + b.append("not_deleted=true"); + return this; + } + } +} \ No newline at end of file
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/LocalReadingListStorage.java @@ -0,0 +1,410 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SYNC_CHANGE_FAVORITE_CHANGED; +import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SYNC_CHANGE_FLAGS; +import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SYNC_CHANGE_UNREAD_CHANGED; +import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SYNC_STATUS; +import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SYNC_STATUS_MODIFIED; +import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SYNC_STATUS_NEW; + +import java.util.ArrayList; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserContract.ReadingListItems; +import org.mozilla.gecko.sync.repositories.android.RepoUtils; + +import android.content.ContentProviderClient; +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentValues; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; + +public class LocalReadingListStorage implements ReadingListStorage { + + private static final String WHERE_STATUS_NEW = "(" + SYNC_STATUS + " = " + SYNC_STATUS_NEW + ")"; + + final class LocalReadingListChangeAccumulator implements ReadingListChangeAccumulator { + private static final String LOG_TAG = "RLChanges"; + + /** + * These are changes that result from uploading new or changed records to the server. + * They always correspond to local records. + */ + private final Queue<ClientReadingListRecord> changes; + + /** + * These are deletions that result from uploading new or changed records to the server. + * They should always correspond to local records. + * These are not common: they should only occur if a conflict occurs. + */ + private final Queue<ClientReadingListRecord> deletions; + + /** + * These are additions or changes fetched from the server. + * At the point of collection we don't know if they're records + * that exist locally. + * + * Batching these here, rather than in the client or the synchronizer, + * puts the storage implementation in control of when batches are flushed, + * or if batches are used at all. + */ + private final Queue<ServerReadingListRecord> additionsOrChanges; + + LocalReadingListChangeAccumulator() { + this.changes = new ConcurrentLinkedQueue<>(); + this.deletions = new ConcurrentLinkedQueue<>(); + this.additionsOrChanges = new ConcurrentLinkedQueue<>(); + } + + public boolean flushDeletions() throws RemoteException { + if (deletions.isEmpty()) { + return true; + } + + long[] ids = new long[deletions.size()]; + String[] guids = new String[deletions.size()]; + int iID = 0; + int iGUID = 0; + for (ClientReadingListRecord record : deletions) { + if (record.clientMetadata.id > -1L) { + ids[iID++] = record.clientMetadata.id; + } else { + final String guid = record.getGUID(); + if (guid == null) { + continue; + } + guids[iGUID++] = guid; + } + } + + if (iID > 0) { + client.delete(URI_WITH_DELETED, RepoUtils.computeSQLLongInClause(ids, ReadingListItems._ID), null); + } + + if (iGUID > 0) { + client.delete(URI_WITH_DELETED, RepoUtils.computeSQLInClause(iGUID, ReadingListItems.GUID), guids); + } + + deletions.clear(); + return true; + } + + public boolean flushRecordChanges() throws RemoteException { + if (changes.isEmpty() && additionsOrChanges.isEmpty()) { + return true; + } + + // For each returned record, apply it to the local store and clear all sync flags. + // We can do this because the server always returns the entire record. + // + // <https://github.com/mozilla-services/readinglist/issues/138> tracks not doing so + // for certain patches, which allows us to optimize here. + ArrayList<ContentProviderOperation> operations = new ArrayList<>(changes.size() + additionsOrChanges.size()); + for (ClientReadingListRecord rec : changes) { + operations.add(makeUpdateOp(rec)); + } + + for (ServerReadingListRecord rec : additionsOrChanges) { + // TODO: skip records for which the local copy of the server timestamp + // matches the timestamp in the incoming record. + // TODO: we can do this by maintaining a lookup table, rather + // than hitting the DB. When we do an insert after an upload, say, we + // can make a note of it so the next download flush doesn't apply it twice. + operations.add(makeUpdateOrInsertOp(rec)); + } + + // TODO: tell delegate of success or failure. + try { + Logger.debug(LOG_TAG, "Applying " + operations.size() + " operations."); + ContentProviderResult[] results = client.applyBatch(operations); + } catch (OperationApplicationException e) { + // Oops. + Logger.warn(LOG_TAG, "Applying operations failed.", e); + return false; + } + return true; + } + + private ContentProviderOperation makeUpdateOrInsertOp(ServerReadingListRecord rec) throws RemoteException { + final ClientReadingListRecord clientRec = new ClientReadingListRecord(rec.serverMetadata, null, rec.fields); + + // TODO: use UPDATE OR INSERT equivalent, rather than querying here. + if (hasGUID(rec.serverMetadata.guid)) { + return makeUpdateOp(clientRec); + } + + final ContentValues values = ReadingListClientContentValuesFactory.fromClientRecord(clientRec); + return ContentProviderOperation.newInsert(URI_WITHOUT_DELETED) + .withValues(values) + .build(); + } + + private ContentProviderOperation makeUpdateOp(ClientReadingListRecord rec) { + // We can't use SQLiteQueryBuilder, because it can't do UPDATE, + // nor can it give us a WHERE clause. + final StringBuilder selection = new StringBuilder(); + final String[] selectionArgs; + + // We don't apply changes that we've already applied. + // We know they've already been applied because our local copy of the + // server's version code/timestamp matches the value in the incoming record. + long serverLastModified = rec.getServerLastModified(); + if (serverLastModified != -1L) { + // This should always be the case here. + selection.append("(" + ReadingListItems.SERVER_LAST_MODIFIED + " IS NOT "); + selection.append(serverLastModified); + selection.append(") AND "); + } + + if (rec.clientMetadata.id > -1L) { + selection.append("("); + selection.append(ReadingListItems._ID + " = "); + selection.append(rec.clientMetadata.id); + selection.append(")"); + selectionArgs = null; + } else if (rec.serverMetadata.guid != null) { + selection.append("(" + ReadingListItems.GUID + " = ?)"); + selectionArgs = new String[] { rec.serverMetadata.guid }; + } else { + final String url = rec.fields.getString("url"); + final String resolvedURL = rec.fields.getString("resolved_url"); + + if (url == null && resolvedURL == null) { + // We're outta luck. + return null; + } + + selection.append("((" + ReadingListItems.URL + " = ?) OR (" + ReadingListItems.RESOLVED_URL + " = ?))"); + if (url != null && resolvedURL != null) { + selectionArgs = new String[] { url, resolvedURL }; + } else { + final String arg = url == null ? resolvedURL : url; + selectionArgs = new String[] { arg, arg }; + } + } + + final ContentValues values = ReadingListClientContentValuesFactory.fromClientRecord(rec); + return ContentProviderOperation.newUpdate(URI_WITHOUT_DELETED) + .withSelection(selection.toString(), selectionArgs) + .withValues(values) + .build(); + } + + @Override + public void finish() throws Exception { + flushDeletions(); + flushRecordChanges(); + } + + @Override + public void addDeletion(ClientReadingListRecord record) { + deletions.add(record); + } + + @Override + public void addChangedRecord(ClientReadingListRecord record) { + changes.add(record); + } + + @Override + public void addUploadedRecord(ClientReadingListRecord up, + ServerReadingListRecord down) { + // TODO + } + + @Override + public void addDownloadedRecord(ServerReadingListRecord down) { + additionsOrChanges.add(down); + } + } + + private final ContentProviderClient client; + private final Uri URI_WITHOUT_DELETED = BrowserContract.READING_LIST_AUTHORITY_URI + .buildUpon() + .appendPath("items") + .appendQueryParameter(BrowserContract.PARAM_IS_SYNC, "1") + .appendQueryParameter(BrowserContract.PARAM_SHOW_DELETED, "0") + .build(); + + private final Uri URI_WITH_DELETED = BrowserContract.READING_LIST_AUTHORITY_URI + .buildUpon() + .appendPath("items") + .appendQueryParameter(BrowserContract.PARAM_IS_SYNC, "1") + .appendQueryParameter(BrowserContract.PARAM_SHOW_DELETED, "1") + .build(); + + public LocalReadingListStorage(final ContentProviderClient client) { + this.client = client; + } + + public boolean hasGUID(String guid) throws RemoteException { + final String[] projection = new String[] { ReadingListItems.GUID }; + final String selection = ReadingListItems.GUID + " = ?"; + final String[] selectionArgs = new String[] { guid }; + final Cursor cursor = this.client.query(URI_WITHOUT_DELETED, projection, selection, selectionArgs, null); + try { + return cursor.moveToFirst(); + } finally { + cursor.close(); + } + } + + public Cursor getModifiedWithSelection(final String selection) { + final String[] projection = new String[] { + ReadingListItems.GUID, + ReadingListItems.IS_FAVORITE, + ReadingListItems.RESOLVED_TITLE, + ReadingListItems.RESOLVED_URL, + ReadingListItems.EXCERPT, + }; + + + try { + return client.query(URI_WITHOUT_DELETED, projection, selection, null, null); + } catch (RemoteException e) { + throw new IllegalStateException(e); + } + } + @Override + public Cursor getModified() { + final String selection = ReadingListItems.SYNC_STATUS + " = " + ReadingListItems.SYNC_STATUS_MODIFIED; + return getModifiedWithSelection(selection); + } + + // Return changed items that aren't just status changes. + // This isn't necessary because we insist on processing status changes before modified items. + // Currently we only need this for tests... + public Cursor getNonStatusModified() { + final String selection = ReadingListItems.SYNC_STATUS + " = " + ReadingListItems.SYNC_STATUS_MODIFIED + + " AND ((" + ReadingListItems.SYNC_CHANGE_FLAGS + " & " + ReadingListItems.SYNC_CHANGE_RESOLVED + ") > 0)"; + + return getModifiedWithSelection(selection); + } + + // These will never conflict (in the case of unread status changes), or + // we don't care if they overwrite the server value (in the case of favorite changes). + // N.B., don't actually send each field if the appropriate change flag isn't set! + @Override + public Cursor getStatusChanges() { + final String[] projection = new String[] { + ReadingListItems.GUID, + ReadingListItems.IS_FAVORITE, + ReadingListItems.IS_UNREAD, + ReadingListItems.MARKED_READ_BY, + ReadingListItems.MARKED_READ_ON, + ReadingListItems.SYNC_CHANGE_FLAGS, + }; + + final String selection = + SYNC_STATUS + " = " + SYNC_STATUS_MODIFIED + " AND " + + "((" + SYNC_CHANGE_FLAGS + " & (" + SYNC_CHANGE_UNREAD_CHANGED + " | " + SYNC_CHANGE_FAVORITE_CHANGED + ")) > 0)"; + + try { + return client.query(URI_WITHOUT_DELETED, projection, selection, null, null); + } catch (RemoteException e) { + throw new IllegalStateException(e); + } + } + + @Override + public Cursor getNew() { + // N.B., query for items that have no GUID, regardless of status. + // They should all be marked as NEW, but belt and braces. + final String selection = WHERE_STATUS_NEW + " OR (" + ReadingListItems.GUID + " IS NULL)"; + + try { + return client.query(URI_WITHOUT_DELETED, null, selection, null, null); + } catch (RemoteException e) { + throw new IllegalStateException(e); + } + } + + @Override + public Cursor getAll() { + try { + return client.query(URI_WITHOUT_DELETED, null, null, null, null); + } catch (RemoteException e) { + throw new IllegalStateException(e); + } + } + + private ContentProviderOperation updateAddedByNames(final String local) { + String[] selectionArgs = new String[] {"$local"}; + String selection = WHERE_STATUS_NEW + " AND (" + ReadingListItems.ADDED_BY + " = ?)"; + return ContentProviderOperation.newUpdate(URI_WITHOUT_DELETED) + .withValue(ReadingListItems.ADDED_BY, local) + .withSelection(selection, selectionArgs) + .build(); + } + + private ContentProviderOperation updateMarkedReadByNames(final String local) { + String[] selectionArgs = new String[] {"$local"}; + String selection = ReadingListItems.MARKED_READ_BY + " = ?"; + return ContentProviderOperation.newUpdate(URI_WITHOUT_DELETED) + .withValue(ReadingListItems.MARKED_READ_BY, local) + .withSelection(selection, selectionArgs) + .build(); + } + + /** + * Consumers of the reading list provider don't know the device name. + * Rather than smearing that logic into callers, or requiring the database + * to be able to figure out the name of the device, we have the SyncAdapter + * do it. + * + * After all, the SyncAdapter knows everything -- prefs, channels, profiles, + * Firefox Account details, etc. + * + * To allow this, the CP writes the magic string "$local" wherever a device + * name is needed. Here in storage, we run a quick UPDATE pass prior to + * synchronizing, so the device name is 'calcified' at the time of the first + * sync of that record. The SyncAdapter calls this prior to invoking the + * synchronizer. + */ + public void updateLocalNames(final String local) { + ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>(2); + ops.add(updateAddedByNames(local)); + ops.add(updateMarkedReadByNames(local)); + + try { + client.applyBatch(ops); + } catch (RemoteException e) { + return; + } catch (OperationApplicationException e) { + return; + } + } + + @Override + public ReadingListChangeAccumulator getChangeAccumulator() { + return new LocalReadingListChangeAccumulator(); + } + + /** + * Unused: we implicitly do this when we apply the server record. + */ + /* + public void markStatusChangedItemsAsSynced(Collection<String> uploaded) { + ContentValues values = new ContentValues(); + values.put(ReadingListItems.SYNC_CHANGE_FLAGS, ReadingListItems.SYNC_CHANGE_NONE); + values.put(ReadingListItems.SYNC_STATUS, ReadingListItems.SYNC_STATUS_SYNCED); + final String where = RepoUtils.computeSQLInClause(uploaded.size(), ReadingListItems.GUID); + final String[] args = uploaded.toArray(new String[uploaded.size()]); + try { + client.update(URI_WITHOUT_DELETED, values, where, args); + } catch (RemoteException e) { + // Nothing we can do. + } + } + */ +}
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListChangeAccumulator.java @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + + +/** + * Grab one of these, then you can add records to it by parsing + * server responses. Finishing it will flush those changes (e.g., + * via UPDATE) to the DB. + */ +public interface ReadingListChangeAccumulator { + void addDeletion(ClientReadingListRecord record); + void addChangedRecord(ClientReadingListRecord record); + void addUploadedRecord(ClientReadingListRecord up, ServerReadingListRecord down); + void addDownloadedRecord(ServerReadingListRecord down); + void finish() throws Exception; +}
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListClient.java @@ -0,0 +1,607 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.util.Queue; +import java.util.concurrent.Executor; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.reading.ReadingListResponse.ResponseFactory; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.BaseResource; +import org.mozilla.gecko.sync.net.BaseResourceDelegate; +import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider; +import org.mozilla.gecko.sync.net.MozResponse; +import org.mozilla.gecko.sync.net.Resource; + +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.client.ClientProtocolException; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; + +/** + * This client exposes an API for the reading list service, documented at + * https://github.com/mozilla-services/readinglist/ + */ +public class ReadingListClient { + static final String LOG_TAG = ReadingListClient.class.getSimpleName(); + private final AuthHeaderProvider auth; + + private final URI articlesURI; // .../articles + private final URI articlesBaseURI; // .../articles/ + + /** + * Use a {@link BasicAuthHeaderProvider} for testing, and an FxA OAuth provider for the real service. + */ + public ReadingListClient(final URI serviceURI, final AuthHeaderProvider auth) { + this.articlesURI = serviceURI.resolve("articles"); + this.articlesBaseURI = serviceURI.resolve("articles/"); + this.auth = auth; + } + + private BaseResource getRelativeArticleResource(final String rel) { + return new BaseResource(this.articlesBaseURI.resolve(rel)); + } + + private static final class DelegatingUploadResourceDelegate extends UploadResourceDelegate<ReadingListRecordResponse> { + private final ClientReadingListRecord up; + private final ReadingListRecordUploadDelegate uploadDelegate; + + DelegatingUploadResourceDelegate(Resource resource, + AuthHeaderProvider auth, + ResponseFactory<ReadingListRecordResponse> factory, + ClientReadingListRecord up, + ReadingListRecordUploadDelegate uploadDelegate) { + super(resource, auth, factory); + this.up = up; + this.uploadDelegate = uploadDelegate; + } + + @Override + void onFailure(MozResponse response) { + Logger.warn(LOG_TAG, "Upload got failure response " + response.httpResponse().getStatusLine()); + response.logResponseBody(LOG_TAG); + if (response.getStatusCode() == 400) { + // Error response. + uploadDelegate.onBadRequest(up, response); + } else { + uploadDelegate.onFailure(up, response); + } + } + + @Override + void onFailure(Exception ex) { + Logger.warn(LOG_TAG, "Upload failed.", ex); + uploadDelegate.onFailure(up, ex); + } + + @Override + void onSuccess(ReadingListRecordResponse response) { + Logger.debug(LOG_TAG, "Upload: onSuccess: " + response.httpResponse().getStatusLine()); + final ServerReadingListRecord down; + try { + down = response.getRecord(); + Logger.debug(LOG_TAG, "Upload succeeded. Got GUID " + down.getGUID()); + } catch (Exception e) { + uploadDelegate.onFailure(up, e); + return; + } + + uploadDelegate.onSuccess(up, response, down); + } + + @Override + void onSeeOther(ReadingListRecordResponse response) { + uploadDelegate.onConflict(up, response); + } + } + + private static abstract class ReadingListResourceDelegate<T extends ReadingListResponse> extends BaseResourceDelegate { + private final ReadingListResponse.ResponseFactory<T> factory; + private final AuthHeaderProvider auth; + + public ReadingListResourceDelegate(Resource resource, AuthHeaderProvider auth, ReadingListResponse.ResponseFactory<T> factory) { + super(resource); + this.auth = auth; + this.factory = factory; + } + + abstract void onSuccess(T response); + abstract void onNonSuccess(T response); + abstract void onFailure(MozResponse response); + abstract void onFailure(Exception ex); + + @Override + public void handleHttpResponse(HttpResponse response) { + final T resp = factory.getResponse(response); + if (resp.wasSuccessful()) { + onSuccess(resp); + } else { + onNonSuccess(resp); + } + } + + @Override + public void handleTransportException(GeneralSecurityException e) { + onFailure(e); + } + + @Override + public void handleHttpProtocolException(ClientProtocolException e) { + onFailure(e); + } + + @Override + public void handleHttpIOException(IOException e) { + onFailure(e); + } + + @Override + public String getUserAgent() { + return ReadingListConstants.USER_AGENT; + } + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return auth; + } + + @Override + public void addHeaders(HttpRequestBase request, DefaultHttpClient client) { + } + } + + /** + * An intermediate delegate class that handles all of the shared storage behavior, + * such as handling If-Modified-Since. + */ + private static abstract class StorageResourceDelegate<T extends ReadingListResponse> extends ReadingListResourceDelegate<T> { + private final long ifModifiedSince; + + public StorageResourceDelegate(Resource resource, + AuthHeaderProvider auth, + ReadingListResponse.ResponseFactory<T> factory, + long ifModifiedSince) { + super(resource, auth, factory); + this.ifModifiedSince = ifModifiedSince; + } + + @Override + public void addHeaders(HttpRequestBase request, DefaultHttpClient client) { + if (ifModifiedSince != -1L) { + // TODO: format? + request.addHeader("If-Modified-Since", "" + ifModifiedSince); + } + super.addHeaders(request, client); + } + } + + /** + * Wraps the @{link ReadingListRecordDelegate} interface to yield a {@link StorageResourceDelegate}. + */ + private static abstract class RecordResourceDelegate<T extends ReadingListResponse> extends StorageResourceDelegate<T> { + protected final ReadingListRecordDelegate recordDelegate; + + public RecordResourceDelegate(Resource resource, + AuthHeaderProvider auth, + ReadingListRecordDelegate recordDelegate, + ReadingListResponse.ResponseFactory<T> factory, + long ifModifiedSince) { + super(resource, auth, factory, ifModifiedSince); + this.recordDelegate = recordDelegate; + } + + abstract void onNotFound(ReadingListResponse resp); + + @Override + void onNonSuccess(T resp) { + Logger.debug(LOG_TAG, "Got non-success record response " + resp.getStatusCode()); + resp.logResponseBody(LOG_TAG); + + switch (resp.getStatusCode()) { + case 304: + onNotModified(resp); + break; + case 404: + onNotFound(resp); + break; + default: + onFailure(resp); + } + } + + @Override + void onFailure(MozResponse response) { + recordDelegate.onFailure(response); + } + + @Override + void onFailure(Exception ex) { + recordDelegate.onFailure(ex); + } + + void onNotModified(T resp) { + recordDelegate.onComplete(resp); + } + } + + private static final class SingleRecordResourceDelegate extends RecordResourceDelegate<ReadingListRecordResponse> { + private final String guid; + + SingleRecordResourceDelegate(Resource resource, + AuthHeaderProvider auth, + ReadingListRecordDelegate recordDelegate, + ResponseFactory<ReadingListRecordResponse> factory, + long ifModifiedSince, String guid) { + super(resource, auth, recordDelegate, factory, ifModifiedSince); + this.guid = guid; + } + + @Override + void onSuccess(ReadingListRecordResponse response) { + final ServerReadingListRecord record; + try { + record = response.getRecord(); + } catch (Exception e) { + recordDelegate.onFailure(e); + return; + } + + recordDelegate.onRecordReceived(record); + recordDelegate.onComplete(response); + } + + @Override + void onNotFound(ReadingListResponse resp) { + recordDelegate.onRecordMissingOrDeleted(guid, resp); + } + } + + private static final class MultipleRecordResourceDelegate extends RecordResourceDelegate<ReadingListStorageResponse> { + MultipleRecordResourceDelegate(Resource resource, + AuthHeaderProvider auth, + ReadingListRecordDelegate recordDelegate, + ResponseFactory<ReadingListStorageResponse> factory, + long ifModifiedSince) { + super(resource, auth, recordDelegate, factory, ifModifiedSince); + } + + @Override + void onSuccess(ReadingListStorageResponse response) { + try { + final Iterable<ServerReadingListRecord> records = response.getRecords(); + for (ServerReadingListRecord readingListRecord : records) { + recordDelegate.onRecordReceived(readingListRecord); + } + } catch (Exception e) { + recordDelegate.onFailure(e); + return; + } + + recordDelegate.onComplete(response); + } + + @Override + void onNotFound(ReadingListResponse resp) { + // Should not occur against articlesURI root. + recordDelegate.onFailure(resp); + } + } + + private static abstract class UploadResourceDelegate<T extends ReadingListResponse> extends StorageResourceDelegate<T> { + public UploadResourceDelegate(Resource resource, + AuthHeaderProvider auth, + ReadingListResponse.ResponseFactory<T> factory, + long ifModifiedSince) { + super(resource, auth, factory, ifModifiedSince); + } + + public UploadResourceDelegate(Resource resource, + AuthHeaderProvider auth, + ReadingListResponse.ResponseFactory<T> factory) { + this(resource, auth, factory, -1L); + } + + @Override + void onNonSuccess(T resp) { + if (resp.getStatusCode() == 303) { + onSeeOther(resp); + return; + } + onFailure(resp); + } + + abstract void onSeeOther(T resp); + } + + + /** + * Recursively calls `patch` with items from the queue, delivering callbacks + * to the provided delegate. Calls `onBatchDone` when the queue is exhausted. + * + * Uses the provided executor to flatten the recursive call stack. + */ + private abstract class BatchingUploadDelegate implements ReadingListRecordUploadDelegate { + private final Queue<ClientReadingListRecord> queue; + private final ReadingListRecordUploadDelegate batchUploadDelegate; + private final Executor executor; + + BatchingUploadDelegate(Queue<ClientReadingListRecord> queue, + ReadingListRecordUploadDelegate batchUploadDelegate, + Executor executor) { + this.queue = queue; + this.batchUploadDelegate = batchUploadDelegate; + this.executor = executor; + } + + abstract void again(ClientReadingListRecord record); + + void next() { + final ClientReadingListRecord record = queue.poll(); + executor.execute(new Runnable() { + @Override + public void run() { + if (record == null) { + batchUploadDelegate.onBatchDone(); + return; + } + + again(record); + } + }); + } + + @Override + public void onSuccess(ClientReadingListRecord up, + ReadingListRecordResponse response, + ServerReadingListRecord down) { + batchUploadDelegate.onSuccess(up, response, down); + next(); + } + + @Override + public void onInvalidUpload(ClientReadingListRecord up, + ReadingListResponse response) { + batchUploadDelegate.onInvalidUpload(up, response); + next(); + } + + @Override + public void onFailure(ClientReadingListRecord up, MozResponse response) { + batchUploadDelegate.onFailure(up, response); + next(); + } + + @Override + public void onFailure(ClientReadingListRecord up, Exception ex) { + batchUploadDelegate.onFailure(up, ex); + next(); + } + + @Override + public void onConflict(ClientReadingListRecord up, + ReadingListResponse response) { + batchUploadDelegate.onConflict(up, response); + next(); + } + + @Override + public void onBadRequest(ClientReadingListRecord up, MozResponse response) { + batchUploadDelegate.onBadRequest(up, response); + next(); + } + + @Override + public void onBatchDone() { + // This should never occur, but if it does, pass through. + batchUploadDelegate.onBatchDone(); + } + } + + private class PostBatchingUploadDelegate extends BatchingUploadDelegate { + PostBatchingUploadDelegate(Queue<ClientReadingListRecord> queue, + ReadingListRecordUploadDelegate batchUploadDelegate, + Executor executor) { + super(queue, batchUploadDelegate, executor); + } + + @Override + void again(ClientReadingListRecord record) { + add(record, PostBatchingUploadDelegate.this); + } + } + + private class PatchBatchingUploadDelegate extends BatchingUploadDelegate { + PatchBatchingUploadDelegate(Queue<ClientReadingListRecord> queue, + ReadingListRecordUploadDelegate batchUploadDelegate, + Executor executor) { + super(queue, batchUploadDelegate, executor); + } + + @Override + void again(ClientReadingListRecord record) { + patch(record, PatchBatchingUploadDelegate.this); + } + } + + // Deliberately declare `delegate` non-final so we can't capture it below. We prefer + // to use `recordDelegate` explicitly. + public void getOne(final String guid, ReadingListRecordDelegate delegate, final long ifModifiedSince) { + final BaseResource r = getRelativeArticleResource(guid); + r.delegate = new SingleRecordResourceDelegate(r, auth, delegate, ReadingListRecordResponse.FACTORY, ifModifiedSince, guid); + if (ReadingListConstants.DEBUG) { + Logger.info(LOG_TAG, "Getting record " + guid); + } + r.get(); + } + + // Deliberately declare `delegate` non-final so we can't capture it below. We prefer + // to use `recordDelegate` explicitly. + public void getAll(final FetchSpec spec, ReadingListRecordDelegate delegate, final long ifModifiedSince) throws URISyntaxException { + final BaseResource r = new BaseResource(spec.getURI(this.articlesURI)); + r.delegate = new MultipleRecordResourceDelegate(r, auth, delegate, ReadingListStorageResponse.FACTORY, ifModifiedSince); + if (ReadingListConstants.DEBUG) { + Logger.info(LOG_TAG, "Getting all records from " + r.getURIString()); + } + r.get(); + } + + /** + * Mutates the provided queue. + */ + public void patch(final Queue<ClientReadingListRecord> queue, final Executor executor, final ReadingListRecordUploadDelegate batchUploadDelegate) { + if (queue.isEmpty()) { + batchUploadDelegate.onBatchDone(); + return; + } + + final ReadingListRecordUploadDelegate uploadDelegate = new PatchBatchingUploadDelegate(queue, batchUploadDelegate, executor); + + patch(queue.poll(), uploadDelegate); + } + + public void patch(final ClientReadingListRecord up, final ReadingListRecordUploadDelegate uploadDelegate) { + final String guid = up.getGUID(); + if (guid == null) { + uploadDelegate.onFailure(up, new IllegalArgumentException("Supplied record must have a GUID.")); + return; + } + + final BaseResource r = getRelativeArticleResource(guid); + r.delegate = new DelegatingUploadResourceDelegate(r, auth, ReadingListRecordResponse.FACTORY, up, + uploadDelegate); + + final ExtendedJSONObject body = up.toJSON(); + if (ReadingListConstants.DEBUG) { + Logger.info(LOG_TAG, "Patching record " + guid + ": " + body.toJSONString()); + } + r.post(body); + } + + /** + * Mutates the provided queue. + */ + public void add(final Queue<ClientReadingListRecord> queue, final Executor executor, final ReadingListRecordUploadDelegate batchUploadDelegate) { + if (queue.isEmpty()) { + batchUploadDelegate.onBatchDone(); + return; + } + + final ReadingListRecordUploadDelegate uploadDelegate = new PostBatchingUploadDelegate(queue, batchUploadDelegate, executor); + + add(queue.poll(), uploadDelegate); + } + + public void add(final ClientReadingListRecord up, final ReadingListRecordUploadDelegate uploadDelegate) { + final BaseResource r = new BaseResource(this.articlesURI); + r.delegate = new DelegatingUploadResourceDelegate(r, auth, ReadingListRecordResponse.FACTORY, up, + uploadDelegate); + + final ExtendedJSONObject body = up.toJSON(); + if (ReadingListConstants.DEBUG) { + Logger.info(LOG_TAG, "Uploading new record: " + body.toJSONString()); + } + r.post(body); + } + + public void delete(final String guid, final ReadingListDeleteDelegate delegate, final long ifUnmodifiedSince) { + final BaseResource r = getRelativeArticleResource(guid); + + // If If-Unmodified-Since is provided, and the record has been modified, + // we'll receive a 412 Precondition Failed. + // If the record is missing or already deleted, a 404 will be returned. + // Otherwise, the response will be the deleted record. + r.delegate = new ReadingListResourceDelegate<ReadingListRecordResponse>(r, auth, ReadingListRecordResponse.FACTORY) { + @Override + public void addHeaders(HttpRequestBase request, DefaultHttpClient client) { + if (ifUnmodifiedSince != -1) { + request.addHeader("If-Unmodified-Since", "" + ifUnmodifiedSince); + } + super.addHeaders(request, client); + } + + @Override + void onFailure(MozResponse response) { + switch (response.getStatusCode()) { + case 412: + delegate.onPreconditionFailed(guid, response); + return; + } + delegate.onFailure(response); + } + + @Override + void onSuccess(ReadingListRecordResponse response) { + final ReadingListRecord record; + try { + record = response.getRecord(); + } catch (Exception e) { + delegate.onFailure(e); + return; + } + + delegate.onSuccess(response, record); + } + + @Override + void onFailure(Exception ex) { + delegate.onFailure(ex); + } + + @Override + void onNonSuccess(ReadingListRecordResponse response) { + if (response.getStatusCode() == 404) { + // Already deleted! + delegate.onRecordMissingOrDeleted(guid, response); + } + } + }; + + if (ReadingListConstants.DEBUG) { + Logger.debug(LOG_TAG, "Deleting " + r.getURIString()); + } + r.delete(); + } + + // TODO: modified times etc. + public void wipe(final ReadingListWipeDelegate delegate) { + Logger.info(LOG_TAG, "Wiping server."); + final BaseResource r = new BaseResource(this.articlesURI); + + r.delegate = new ReadingListResourceDelegate<ReadingListStorageResponse>(r, auth, ReadingListStorageResponse.FACTORY) { + + @Override + void onSuccess(ReadingListStorageResponse response) { + Logger.info(LOG_TAG, "Wipe succeded."); + delegate.onSuccess(response); + } + + @Override + void onNonSuccess(ReadingListStorageResponse response) { + Logger.warn(LOG_TAG, "Wipe failed: " + response.getStatusCode()); + onFailure(response); + } + + @Override + void onFailure(MozResponse response) { + Logger.warn(LOG_TAG, "Wipe failed: " + response.getStatusCode()); + delegate.onFailure(response); + } + + @Override + void onFailure(Exception ex) { + Logger.warn(LOG_TAG, "Wipe failed.", ex); + delegate.onFailure(ex); + } + }; + + r.delete(); + } +}
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListClientContentValuesFactory.java @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import java.util.Map.Entry; +import java.util.Set; + +import org.mozilla.gecko.db.BrowserContract.ReadingListItems; +import org.mozilla.gecko.reading.ReadingListRecord.ServerMetadata; +import org.mozilla.gecko.sync.ExtendedJSONObject; + +import android.content.ContentValues; + +public class ReadingListClientContentValuesFactory { + public static ContentValues fromClientRecord(ClientReadingListRecord record) { + // Do each of these. + ExtendedJSONObject fields = record.fields; + ServerMetadata sm = record.serverMetadata; + + final ContentValues values = new ContentValues(); + + if (sm.guid != null) { + values.put(ReadingListItems.GUID, sm.guid); + } + + if (sm.lastModified > -1L) { + values.put(ReadingListItems.SERVER_LAST_MODIFIED, sm.lastModified); + } + + final Set<Entry<String, Object>> entries = fields.entrySet(); + + for (Entry<String,Object> entry : entries) { + final String key = entry.getKey(); + final String field = mapField(key); + if (field == null) { + continue; + } + + final Object v = entry.getValue(); + if (v == null) { + values.putNull(field); + } else if (v instanceof Boolean) { + values.put(field, ((Boolean) v) ? 1 : 0); + } else if (v instanceof Long) { + values.put(field, (Long) v); + } else if (v instanceof Integer) { + values.put(field, (Integer) v); + } else if (v instanceof String) { + values.put(field, (String) v); + } else if (v instanceof Double) { + values.put(field, (Double) v); + } else { + throw new IllegalArgumentException("Unknown value " + v + " of type " + v.getClass().getSimpleName()); + } + } + + // Clear the sync flags. + values.put(ReadingListItems.SYNC_STATUS, ReadingListItems.SYNC_STATUS_SYNCED); + values.put(ReadingListItems.SYNC_CHANGE_FLAGS, ReadingListItems.SYNC_CHANGE_NONE); + + return values; + } + + /** + * Only returns valid columns. + */ + private static String mapField(String key) { + if (key == null) { + return null; + } + + switch (key) { + case "unread": + return "is_unread"; + case "favorite": + return "is_favorite"; + case "archived": + return "is_archived"; + case "deleted": + return "is_deleted"; + } + + // Validation. + for (int i = 0; i < ReadingListItems.ALL_FIELDS.length; ++i) { + if (key.equals(ReadingListItems.ALL_FIELDS[i])) { + return key; + } + } + + return null; + } +}
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListClientRecordFactory.java @@ -0,0 +1,221 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.db.BrowserContract.ReadingListItems; +import org.mozilla.gecko.reading.ReadingListRecord.ServerMetadata; +import org.mozilla.gecko.sync.ExtendedJSONObject; + +import android.annotation.TargetApi; +import android.database.Cursor; +import android.database.CursorWindow; +import android.database.sqlite.SQLiteCursor; +import android.os.Build; + +/** + * This class converts database rows into {@link ClientReadingListRecord}s. + * + * In doing so it has to: + * + * * Translate column names. + * * Convert INTEGER columns into booleans. + * * Eliminate fields that aren't present in the wire format. + * * Extract fields that are part of {@link ClientMetadata} instances. + * + * The caller is responsible for closing the cursor. + */ +public class ReadingListClientRecordFactory { + public static final int MAX_SERVER_STRING_CHARS = 1024; + + private final Cursor cursor; + + private final String[] fields; + private final int[] columns; + + public ReadingListClientRecordFactory(final Cursor cursor, final String[] fields) throws IllegalArgumentException { + this.cursor = cursor; + + // Does this cursor have an _ID? + final int idIndex = cursor.getColumnIndex(ReadingListItems._ID); + final int extra = (idIndex != -1) ? 1 : 0; + final int cols = fields.length + extra; + + this.fields = new String[cols]; + this.columns = new int[cols]; + + for (int i = 0; i < fields.length; ++i) { + final int index = cursor.getColumnIndex(fields[i]); + if (index == -1) { + continue; + } + this.fields[i] = mapColumn(fields[i]); + this.columns[i] = index; + } + + if (idIndex != -1) { + this.fields[fields.length] = "_id"; + this.columns[fields.length] = idIndex; + } + } + + public ReadingListClientRecordFactory(final Cursor cursor) { + this(cursor, ReadingListItems.ALL_FIELDS); + } + + private void putNull(ExtendedJSONObject o, String field) { + o.put(field, null); + } + + /** + * Map column names to protocol field names. + */ + private static String mapColumn(final String column) { + switch (column) { + case "is_unread": + return "unread"; + case "is_favorite": + return "favorite"; + case "is_archived": + return "archived"; + } + return column; + } + + private void put(ExtendedJSONObject o, String field, String value) { + // All server strings are a max of 1024 characters. + o.put(field, value.length() > MAX_SERVER_STRING_CHARS ? value.substring(0, MAX_SERVER_STRING_CHARS - 1) + "…" : value); + } + + private void put(ExtendedJSONObject o, String field, long value) { + // Convert to boolean. + switch (field) { + case "unread": + case "favorite": + case "archived": + case "is_article": + o.put(field, value == 1); + return; + } + o.put(field, value); + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + private final void fillHoneycomb(ExtendedJSONObject o, Cursor c, String f, int i) { + if (f == null) { + return; + } + switch (c.getType(i)) { + case Cursor.FIELD_TYPE_NULL: + putNull(o, f); + return; + case Cursor.FIELD_TYPE_STRING: + put(o, f, c.getString(i)); + return; + case Cursor.FIELD_TYPE_INTEGER: + put(o, f, c.getLong(i)); + return; + case Cursor.FIELD_TYPE_FLOAT: + o.put(f, c.getDouble(i)); + return; + case Cursor.FIELD_TYPE_BLOB: + // TODO: this probably doesn't serialize correctly. + o.put(f, c.getBlob(i)); + return; + default: + // Do nothing. + return; + } + } + + @SuppressWarnings("deprecation") + private final void fillGingerbread(ExtendedJSONObject o, Cursor c, String f, int i) { + if (!(c instanceof SQLiteCursor)) { + throw new IllegalStateException("Unable to handle cursors that don't have a CursorWindow!"); + } + + final SQLiteCursor sqc = (SQLiteCursor) c; + final CursorWindow w = sqc.getWindow(); + final int pos = c.getPosition(); + if (w.isNull(pos, i)) { + putNull(o, f); + } else if (w.isString(pos, i)) { + put(o, f, c.getString(i)); + } else if (w.isLong(pos, i)) { + put(o, f, c.getLong(i)); + } else if (w.isFloat(pos, i)) { + o.put(f, c.getDouble(i)); + } else if (w.isBlob(pos, i)) { + // TODO: this probably doesn't serialize correctly. + o.put(f, c.getBlob(i)); + } + } + + /** + * TODO: optionally produce a partial record by examining SYNC_CHANGE_FLAGS/SYNC_STATUS. + */ + public ClientReadingListRecord fromCursorRow() { + final ExtendedJSONObject object = new ExtendedJSONObject(); + for (int i = 0; i < this.fields.length; ++i) { + final String field = fields[i]; + if (field == null) { + continue; + } + final int column = this.columns[i]; + if (Versions.feature11Plus) { + fillHoneycomb(object, this.cursor, field, column); + } else { + fillGingerbread(object, this.cursor, field, column); + } + } + + // Apply cross-field constraints. + if (object.containsKey("unread") && object.getBoolean("unread")) { + object.remove("marked_read_by"); + object.remove("marked_read_on"); + } + + // Construct server metadata and client metadata from the object. + final long serverLastModified = object.getLong("last_modified", -1L); + final String guid = object.containsKey("guid") ? object.getString("guid") : null; + final ServerMetadata sm = new ServerMetadata(guid, serverLastModified); + + final long clientLastModified = object.getLong("client_last_modified", -1L); + + // This has already been translated... + final boolean isArchived = object.getBoolean("archived"); + + // ... but this is a client-only field, so it needs to be converted. + final boolean isDeleted = object.getLong("is_deleted", 0L) == 1L; + final long localID = object.getLong("_id", -1L); + final ClientMetadata cm = new ClientMetadata(localID, clientLastModified, isDeleted, isArchived); + + // Remove things that aren't part of the spec. + object.remove("last_modified"); + object.remove("guid"); + object.remove("client_last_modified"); + object.remove("is_deleted"); + + object.remove(ReadingListItems.CONTENT_STATUS); + object.remove(ReadingListItems.SYNC_STATUS); + object.remove(ReadingListItems.SYNC_CHANGE_FLAGS); + object.remove(ReadingListItems.CLIENT_LAST_MODIFIED); + + return new ClientReadingListRecord(sm, cm, object); + } + + /** + * Return a record from a cursor. + * Make sure that the columns you specify in the constructor are a subset + * of the columns in the cursor, or you'll have a bad time. + */ + public ClientReadingListRecord getNext() { + if (!cursor.moveToNext()) { + return null; + } + + return fromCursorRow(); + } +} \ No newline at end of file
--- a/mobile/android/base/reading/ReadingListConstants.java +++ b/mobile/android/base/reading/ReadingListConstants.java @@ -6,9 +6,13 @@ package org.mozilla.gecko.reading; import org.mozilla.gecko.AppConstants; public class ReadingListConstants { public static final String GLOBAL_LOG_TAG = "FxReadingList"; public static final String USER_AGENT = "Firefox-Android-FxReader/" + AppConstants.MOZ_APP_VERSION + " (" + AppConstants.MOZ_APP_DISPLAYNAME + ")"; public static final String DEFAULT_DEV_ENDPOINT = "https://readinglist.dev.mozaws.net/v1/"; public static final String DEFAULT_PROD_ENDPOINT = null; // TODO + + public static final String OAUTH_ENDPOINT_PROD = "https://oauth.accounts.firefox.com/v1"; + + public static boolean DEBUG = false; }
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListDeleteDelegate.java @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import org.mozilla.gecko.sync.net.MozResponse; + +/** + * Response delegate for a server DELETE. + * Only one of these methods will be called, and it will be called precisely once. + */ +public interface ReadingListDeleteDelegate { + void onSuccess(ReadingListRecordResponse response, ReadingListRecord record); + void onPreconditionFailed(String guid, MozResponse response); + void onRecordMissingOrDeleted(String guid, MozResponse response); + void onFailure(Exception e); + void onFailure(MozResponse response); +}
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListRecord.java @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import org.mozilla.gecko.sync.ExtendedJSONObject; + +/** + * This models the wire protocol format, not database contents. + */ +public abstract class ReadingListRecord { + public static class ServerMetadata { + public final String guid; // Null if not yet uploaded successfully. + public final long lastModified; // A server timestamp. + + public ServerMetadata(String guid, long lastModified) { + this.guid = guid; + this.lastModified = lastModified; + } + + /** + * From server record. + */ + public ServerMetadata(ExtendedJSONObject obj) { + this(obj.getString("id"), obj.getLong("last_modified")); + } + } + + public final ServerMetadata serverMetadata; + + public String getGUID() { + if (serverMetadata == null) { + return null; + } + + return serverMetadata.guid; + } + + public long getServerLastModified() { + if (serverMetadata == null) { + return -1L; + } + + return serverMetadata.lastModified; + } + + protected ReadingListRecord(final ServerMetadata serverMetadata) { + this.serverMetadata = serverMetadata; + } + + public abstract String getURL(); + public abstract String getTitle(); + public abstract String getAddedBy(); +}
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListRecordDelegate.java @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import org.mozilla.gecko.sync.net.MozResponse; + +/** + * Delegate for downloading records. + * + * onRecordReceived will be called at most once per record. + * onComplete will be called at the end of a successful download. + * + * Otherwise, one of the failure methods will be called. + * + * onRecordMissingOrDeleted will only be called when fetching a single + * record by ID. + */ +public interface ReadingListRecordDelegate { + void onRecordReceived(ServerReadingListRecord record); + void onComplete(ReadingListResponse response); + void onFailure(MozResponse response); + void onFailure(Exception error); + void onRecordMissingOrDeleted(String guid, ReadingListResponse resp); +}
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListRecordResponse.java @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import java.io.IOException; + +import org.json.simple.parser.ParseException; +import org.mozilla.gecko.sync.NonObjectJSONException; + +import ch.boye.httpclientandroidlib.HttpResponse; + +/** + * A storage response that contains a single record. + */ +public class ReadingListRecordResponse extends ReadingListResponse { + @Override + public boolean wasSuccessful() { + final int code = getStatusCode(); + if (code == 200 || code == 201 || code == 204) { + return true; + } + return super.wasSuccessful(); + } + + public static final ReadingListResponse.ResponseFactory<ReadingListRecordResponse> FACTORY = new ReadingListResponse.ResponseFactory<ReadingListRecordResponse>() { + @Override + public ReadingListRecordResponse getResponse(HttpResponse r) { + return new ReadingListRecordResponse(r); + } + }; + + public ReadingListRecordResponse(HttpResponse res) { + super(res); + } + + public ServerReadingListRecord getRecord() throws IllegalStateException, NonObjectJSONException, IOException, ParseException { + return new ServerReadingListRecord(jsonObjectBody()); + } +} \ No newline at end of file
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListRecordUploadDelegate.java @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import org.mozilla.gecko.sync.net.MozResponse; + +public interface ReadingListRecordUploadDelegate { + // Called once per batch. + public void onBatchDone(); + + // One of these is called once per record. + public void onSuccess(ClientReadingListRecord up, ReadingListRecordResponse response, ServerReadingListRecord down); + public void onConflict(ClientReadingListRecord up, ReadingListResponse response); + public void onInvalidUpload(ClientReadingListRecord up, ReadingListResponse response); + public void onBadRequest(ClientReadingListRecord up, MozResponse response); + public void onFailure(ClientReadingListRecord up, Exception ex); + public void onFailure(ClientReadingListRecord up, MozResponse response); +}
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListResponse.java @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import org.mozilla.gecko.sync.net.MozResponse; + +import ch.boye.httpclientandroidlib.HttpResponse; + +/** + * A MozResponse that knows about all of the general RL-related headers, like Last-Modified. + */ +public abstract class ReadingListResponse extends MozResponse { + static interface ResponseFactory<T extends ReadingListResponse> { + public T getResponse(HttpResponse r); + } + + public ReadingListResponse(HttpResponse res) { + super(res); + } + + public long getLastModified() { + return getLongHeader("Last-Modified"); + } +} \ No newline at end of file
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListStorage.java @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import android.database.Cursor; + +public interface ReadingListStorage { + Cursor getModified(); + Cursor getStatusChanges(); + Cursor getNew(); + Cursor getAll(); + ReadingListChangeAccumulator getChangeAccumulator(); +}
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListStorageResponse.java @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import java.io.IOException; +import java.util.Iterator; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.ParseException; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.UnexpectedJSONException; + +import ch.boye.httpclientandroidlib.HttpResponse; + +/** + * A storage response that contains multiple records. + */ +public class ReadingListStorageResponse extends ReadingListResponse { + public static final ReadingListResponse.ResponseFactory<ReadingListStorageResponse> FACTORY = new ReadingListResponse.ResponseFactory<ReadingListStorageResponse>() { + @Override + public ReadingListStorageResponse getResponse(HttpResponse r) { + return new ReadingListStorageResponse(r); + } + }; + + private static final String LOG_TAG = "StorageResponse"; + + public ReadingListStorageResponse(HttpResponse res) { + super(res); + } + + public Iterable<ServerReadingListRecord> getRecords() throws IOException, ParseException, UnexpectedJSONException { + final ExtendedJSONObject body = jsonObjectBody(); + final JSONArray items = body.getArray("items"); + + final int expected = getTotalRecords(); + final int actual = items.size(); + if (actual < expected) { + Logger.warn(LOG_TAG, "Unexpected number of records. Got " + actual + ", expected " + expected); + } + + return new Iterable<ServerReadingListRecord>() { + @Override + public Iterator<ServerReadingListRecord> iterator() { + return new Iterator<ServerReadingListRecord>() { + int position = 0; + + @Override + public boolean hasNext() { + return position < actual; + } + + @Override + public ServerReadingListRecord next() { + final Object o = items.get(position++); + return new ServerReadingListRecord(new ExtendedJSONObject((JSONObject) o)); + } + + @Override + public void remove() { + throw new RuntimeException("Cannot remove from iterator."); + } + }; + } + }; + } + + public int getTotalRecords() { + return getIntegerHeader("Total-Records"); + } +} \ No newline at end of file
--- a/mobile/android/base/reading/ReadingListSyncAdapter.java +++ b/mobile/android/base/reading/ReadingListSyncAdapter.java @@ -1,25 +1,294 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.gecko.reading; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.NoSuchAlgorithmException; +import java.util.Collection; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.mozilla.gecko.background.common.PrefsBranch; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.FxAccountClient; +import org.mozilla.gecko.background.fxa.FxAccountClient20; +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient.RequestDelegate; +import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException.FxAccountAbstractClientRemoteException; +import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10; +import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10.AuthorizationResponse; +import org.mozilla.gecko.browserid.BrowserIDKeyPair; +import org.mozilla.gecko.browserid.JSONWebTokenUtils; +import org.mozilla.gecko.db.BrowserContract.ReadingListItems; import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine; +import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.LoginStateMachineDelegate; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition; +import org.mozilla.gecko.fxa.login.Married; +import org.mozilla.gecko.fxa.login.State; +import org.mozilla.gecko.fxa.login.State.StateLabel; +import org.mozilla.gecko.fxa.login.StateFactory; +import org.mozilla.gecko.fxa.sync.FxAccountSyncDelegate; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.BearerAuthHeaderProvider; import android.accounts.Account; import android.content.AbstractThreadedSyncAdapter; import android.content.ContentProviderClient; +import android.content.ContentResolver; import android.content.Context; +import android.content.SharedPreferences; import android.content.SyncResult; import android.os.Bundle; public class ReadingListSyncAdapter extends AbstractThreadedSyncAdapter { - public ReadingListSyncAdapter(Context context, boolean autoInitialize) { - super(context, autoInitialize); + public static final String PREF_LOCAL_NAME = "device.localname"; + public static final String OAUTH_CLIENT_ID_FENNEC = "3332a18d142636cb"; + public static final String OAUTH_SCOPE_READINGLIST = "readinglist"; + + private static final String LOG_TAG = ReadingListSyncAdapter.class.getSimpleName(); + private static final long TIMEOUT_SECONDS = 60; + protected final ExecutorService executor; + + public ReadingListSyncAdapter(Context context, boolean autoInitialize) { + super(context, autoInitialize); + this.executor = Executors.newSingleThreadExecutor(); + } + + + static final class SyncAdapterSynchronizerDelegate implements ReadingListSynchronizerDelegate { + private final FxAccountSyncDelegate syncDelegate; + private final ContentProviderClient cpc; + private final SyncResult result; + + SyncAdapterSynchronizerDelegate(FxAccountSyncDelegate syncDelegate, + ContentProviderClient cpc, + SyncResult result) { + this.syncDelegate = syncDelegate; + this.cpc = cpc; + this.result = result; + } + + @Override + public void onUnableToSync(Exception e) { + Logger.warn(LOG_TAG, "Unable to sync.", e); + cpc.release(); + syncDelegate.handleError(e); + } + + @Override + public void onStatusUploadComplete(Collection<String> uploaded, + Collection<String> failed) { + Logger.debug(LOG_TAG, "Step: onStatusUploadComplete"); + this.result.stats.numEntries += 1; // TODO: Bug 1140809. + } + + @Override + public void onNewItemUploadComplete(Collection<String> uploaded, + Collection<String> failed) { + Logger.debug(LOG_TAG, "Step: onNewItemUploadComplete"); + this.result.stats.numEntries += 1; // TODO: Bug 1140809. + } + + @Override + public void onModifiedUploadComplete() { + Logger.debug(LOG_TAG, "Step: onModifiedUploadComplete"); + this.result.stats.numEntries += 1; // TODO: Bug 1140809. + } + + @Override + public void onDownloadComplete() { + Logger.debug(LOG_TAG, "Step: onDownloadComplete"); + this.result.stats.numInserts += 1; // TODO: Bug 1140809. } @Override - public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { - final AndroidFxAccount fxAccount = new AndroidFxAccount(getContext(), account); + public void onComplete() { + Logger.info(LOG_TAG, "Reading list synchronization complete."); + cpc.release(); + syncDelegate.handleSuccess(); } + } + + @Override + public void onPerformSync(final Account account, final Bundle extras, final String authority, final ContentProviderClient provider, final SyncResult syncResult) { + Logger.setThreadLogTag(ReadingListConstants.GLOBAL_LOG_TAG); + Logger.resetLogging(); + + final Context context = getContext(); + final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account); + + // If this sync was triggered by user action, this will be true. + final boolean isImmediate = (extras != null) && + (extras.getBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, false) || + extras.getBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, false)); + + final CountDownLatch latch = new CountDownLatch(1); + final FxAccountSyncDelegate syncDelegate = new FxAccountSyncDelegate(latch, syncResult, fxAccount); + try { + final State state; + try { + state = fxAccount.getState(); + } catch (Exception e) { + Logger.error(LOG_TAG, "Unable to sync.", e); + return; + } + + final String oauthServerUri = ReadingListConstants.OAUTH_ENDPOINT_PROD; + final String authServerEndpoint = fxAccount.getAccountServerURI(); + final String audience = FxAccountUtils.getAudienceForURL(oauthServerUri); // The assertion gets traded in for an oauth bearer token. + + final SharedPreferences sharedPrefs = fxAccount.getReadingListPrefs(); + final FxAccountClient client = new FxAccountClient20(authServerEndpoint, executor); + final FxAccountLoginStateMachine stateMachine = new FxAccountLoginStateMachine(); + + stateMachine.advance(state, StateLabel.Married, new LoginStateMachineDelegate() { + @Override + public FxAccountClient getClient() { + return client; + } + + @Override + public long getCertificateDurationInMilliseconds() { + return 12 * 60 * 60 * 1000; + } + + @Override + public long getAssertionDurationInMilliseconds() { + return 15 * 60 * 1000; + } + + @Override + public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException { + return StateFactory.generateKeyPair(); + } + + @Override + public void handleTransition(Transition transition, State state) { + Logger.info(LOG_TAG, "handleTransition: " + transition + " to " + state.getStateLabel()); + } + + @Override + public void handleFinal(State state) { + Logger.info(LOG_TAG, "handleFinal: in " + state.getStateLabel()); + fxAccount.setState(state); + + // TODO: scheduling, notifications. + try { + if (state.getStateLabel() != StateLabel.Married) { + syncDelegate.handleCannotSync(state); + return; + } + + final Married married = (Married) state; + final String assertion = married.generateAssertion(audience, JSONWebTokenUtils.DEFAULT_ASSERTION_ISSUER); + JSONWebTokenUtils.dumpAssertion(assertion); + + final String clientID = OAUTH_CLIENT_ID_FENNEC; + final String scope = OAUTH_SCOPE_READINGLIST; + syncWithAssertion(clientID, scope, assertion, sharedPrefs, extras); + } catch (Exception e) { + syncDelegate.handleError(e); + return; + } + } + + private void syncWithAssertion(final String client_id, final String scope, final String assertion, + final SharedPreferences sharedPrefs, final Bundle extras) { + final FxAccountOAuthClient10 oauthClient = new FxAccountOAuthClient10(oauthServerUri, executor); + Logger.debug(LOG_TAG, "OAuth fetch."); + oauthClient.authorization(client_id, assertion, null, scope, new RequestDelegate<FxAccountOAuthClient10.AuthorizationResponse>() { + @Override + public void handleSuccess(AuthorizationResponse result) { + Logger.debug(LOG_TAG, "OAuth success."); + syncWithAuthorization(result, sharedPrefs, extras); + } + + @Override + public void handleFailure(FxAccountAbstractClientRemoteException e) { + Logger.error(LOG_TAG, "OAuth failure.", e); + syncDelegate.handleError(e); + } + + @Override + public void handleError(Exception e) { + Logger.error(LOG_TAG, "OAuth error.", e); + syncDelegate.handleError(e); + } + }); + } + + private void syncWithAuthorization(AuthorizationResponse authResponse, + SharedPreferences sharedPrefs, + Bundle extras) { + final AuthHeaderProvider auth = new BearerAuthHeaderProvider(authResponse.access_token); + + final String endpointString = ReadingListConstants.DEFAULT_DEV_ENDPOINT; + final URI endpoint; + Logger.info(LOG_TAG, "XXX Syncing to " + endpointString); + try { + endpoint = new URI(endpointString); + } catch (URISyntaxException e) { + // Should never happen. + Logger.error(LOG_TAG, "Unexpected malformed URI for reading list service: " + endpointString); + syncDelegate.handleError(e); + return; + } + + final PrefsBranch branch = new PrefsBranch(sharedPrefs, "readinglist."); + final ReadingListClient remote = new ReadingListClient(endpoint, auth); + final ContentProviderClient cpc = getContentProviderClient(context); // TODO: make sure I'm always released! + + final LocalReadingListStorage local = new LocalReadingListStorage(cpc); + String localName = branch.getString(PREF_LOCAL_NAME, null); + if (localName == null) { + localName = FxAccountUtils.defaultClientName(context); + } + + // Make sure DB rows don't refer to placeholder values. + local.updateLocalNames(localName); + + final ReadingListSynchronizer synchronizer = new ReadingListSynchronizer(branch, remote, local); + + synchronizer.syncAll(new SyncAdapterSynchronizerDelegate(syncDelegate, cpc, syncResult)); + // TODO: backoffs, and everything else handled by a SessionCallback. + } + }); + + latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + Logger.info(LOG_TAG, "Reading list sync done."); + + } catch (Exception e) { + Logger.error(LOG_TAG, "Got error syncing.", e); + syncDelegate.handleError(e); + } + /* + * TODO: + * * Account error notifications. How do we avoid these overlapping with Sync? + * * Pickling. How do we avoid pickling twice if you use both Sync and RL? + */ + + /* + * TODO: + * * Auth. + * * Server URI lookup. + * * Syncing. + * * Error handling. + * * Backoff and retry-after. + * * Sync scheduling. + * * Forcing syncs/interactive use. + */ + } + + + private ContentProviderClient getContentProviderClient(Context context) { + final ContentResolver contentResolver = context.getContentResolver(); + final ContentProviderClient client = contentResolver.acquireContentProviderClient(ReadingListItems.CONTENT_URI); + return client; + } }
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListSynchronizer.java @@ -0,0 +1,645 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.LinkedList; +import java.util.Queue; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import org.json.simple.parser.ParseException; +import org.mozilla.gecko.background.common.PrefsBranch; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.db.BrowserContract.ReadingListItems; +import org.mozilla.gecko.reading.ReadingListRecord.ServerMetadata; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonObjectJSONException; +import org.mozilla.gecko.sync.net.MozResponse; + +import android.database.Cursor; +import android.text.TextUtils; + +/** + * This class implements the multi-phase synchronizing approach described + * at <https://github.com/mozilla-services/readinglist/wiki/Client-phases>. + * + * This is also where delegate-based control flow comes to die. + */ +public class ReadingListSynchronizer { + public static final String LOG_TAG = ReadingListSynchronizer.class.getSimpleName(); + + public static final String PREF_LAST_MODIFIED = "download.serverlastmodified"; + + private final PrefsBranch prefs; + private final ReadingListClient remote; + private final ReadingListStorage local; + private final Executor executor; + + private interface StageDelegate { + void next(); + void fail(); + void fail(Exception e); + } + + private abstract static class NextDelegate implements StageDelegate { + private final Executor executor; + NextDelegate(final Executor executor) { + this.executor = executor; + } + + abstract void doNext(); + abstract void doFail(Exception e); + + @Override + public void next() { + executor.execute(new Runnable() { + @Override + public void run() { + doNext(); + } + }); + } + + @Override + public void fail() { + fail(null); + } + + @Override + public void fail(final Exception e) { + executor.execute(new Runnable() { + @Override + public void run() { + doFail(e); + } + }); + } + } + + public ReadingListSynchronizer(final PrefsBranch prefs, final ReadingListClient remote, final ReadingListStorage local) { + this(prefs, remote, local, Executors.newSingleThreadExecutor()); + } + + public ReadingListSynchronizer(final PrefsBranch prefs, final ReadingListClient remote, final ReadingListStorage local, Executor executor) { + this.prefs = prefs; + this.remote = remote; + this.local = local; + this.executor = executor; + } + + private static final class NewItemUploadDelegate implements ReadingListRecordUploadDelegate { + public volatile int failures = 0; + private final ReadingListChangeAccumulator acc; + private final StageDelegate next; + + NewItemUploadDelegate(ReadingListChangeAccumulator acc, StageDelegate next) { + this.acc = acc; + this.next = next; + } + + @Override + public void onSuccess(ClientReadingListRecord up, + ReadingListRecordResponse response, + ServerReadingListRecord down) { + // Apply the resulting record. The server will have populated some fields. + acc.addChangedRecord(up.givenServerRecord(down)); + } + + @Override + public void onConflict(ClientReadingListRecord up, ReadingListResponse response) { + ExtendedJSONObject body; + try { + body = response.jsonObjectBody(); + String conflicting = body.getString("id"); + Logger.warn(LOG_TAG, "Conflict detected: remote ID is " + conflicting); + + // TODO: When an operation implies that a server record is a replacement + // of what we uploaded, we should ensure that we have a local copy of + // that server record! + } catch (IllegalStateException | NonObjectJSONException | IOException | + ParseException e) { + // Oops. + // But our workaround is the same either way. + } + + // Either the record exists locally, in which case we need to merge, + // or it doesn't, and we'll download it shortly. + // The simplest thing to do in both cases is to simply delete the local + // record we tried to upload. Yes, we might lose some annotations, but + // we can leave doing better to a follow-up. + // Issues here are so unlikely that we don't do anything sophisticated + // (like moving the record to a holding area) -- just delete it ASAP. + acc.addDeletion(up); + } + + @Override + public void onInvalidUpload(ClientReadingListRecord up, ReadingListResponse response) { + recordFailed(up); + } + + @Override + public void onFailure(ClientReadingListRecord up, MozResponse response) { + recordFailed(up); + } + + @Override + public void onFailure(ClientReadingListRecord up, Exception ex) { + recordFailed(up); + } + + @Override + public void onBadRequest(ClientReadingListRecord up, MozResponse response) { + recordFailed(up); + } + + private void recordFailed(ClientReadingListRecord up) { + ++failures; + } + + @Override + public void onBatchDone() { + // We mark uploaded records as synced when we apply the server record with the + // GUID -- we don't know the GUID yet! + if (failures == 0) { + try { + next.next(); + } catch (Exception e) { + next.fail(e); + } + return; + } + next.fail(); + } + } + + private static class StatusUploadDelegate implements ReadingListRecordUploadDelegate { + private final ReadingListChangeAccumulator acc; + + public volatile int failures = 0; + private final StageDelegate next; + + StatusUploadDelegate(ReadingListChangeAccumulator acc, StageDelegate next) { + this.acc = acc; + this.next = next; + } + + @Override + public void onInvalidUpload(ClientReadingListRecord up, + ReadingListResponse response) { + recordFailed(up); + } + + @Override + public void onConflict(ClientReadingListRecord up, + ReadingListResponse response) { + // This should never happen for a status-only change. + // TODO: mark this record as requiring a full upload or download. + failures++; + } + + @Override + public void onSuccess(ClientReadingListRecord up, + ReadingListRecordResponse response, + ServerReadingListRecord down) { + if (!TextUtils.equals(up.getGUID(), down.getGUID())) { + // Uh oh! + // This should never occur. We should get an onConflict instead, + // so this would imply a server bug, or something like a truncated + // over-long GUID string. + // + // Should we wish to recover from this case, probably the right approach + // is to ensure that the GUID is overwritten locally (given that we know + // the numeric ID). + } + + acc.addChangedRecord(up.givenServerRecord(down)); + } + + @Override + public void onBadRequest(ClientReadingListRecord up, MozResponse response) { + recordFailed(up); + } + + @Override + public void onFailure(ClientReadingListRecord up, Exception ex) { + recordFailed(up); + } + + @Override + public void onFailure(ClientReadingListRecord up, MozResponse response) { + recordFailed(up); + } + + private void recordFailed(ClientReadingListRecord up) { + ++failures; + } + + @Override + public void onBatchDone() { + try { + acc.finish(); + } catch (Exception e) { + next.fail(e); + return; + } + + if (failures == 0) { + try { + next.next(); + } catch (Exception e) { + } + } + next.fail(); + } + } + + private Queue<ClientReadingListRecord> collectStatusChangesFromCursor(final Cursor cursor) { + try { + final Queue<ClientReadingListRecord> toUpload = new LinkedList<>(); + + // The columns should come in this order, FWIW. + final int columnGUID = cursor.getColumnIndexOrThrow(ReadingListItems.GUID); + final int columnIsUnread = cursor.getColumnIndexOrThrow(ReadingListItems.IS_UNREAD); + final int columnIsFavorite = cursor.getColumnIndexOrThrow(ReadingListItems.IS_FAVORITE); + final int columnMarkedReadBy = cursor.getColumnIndexOrThrow(ReadingListItems.MARKED_READ_BY); + final int columnMarkedReadOn = cursor.getColumnIndexOrThrow(ReadingListItems.MARKED_READ_ON); + final int columnChangeFlags = cursor.getColumnIndexOrThrow(ReadingListItems.SYNC_CHANGE_FLAGS); + + while (cursor.moveToNext()) { + final String guid = cursor.getString(columnGUID); + if (guid == null) { + // Nothing we can do here. + continue; + } + + final ExtendedJSONObject o = new ExtendedJSONObject(); + o.put("id", guid); + + final int changeFlags = cursor.getInt(columnChangeFlags); + if ((changeFlags & ReadingListItems.SYNC_CHANGE_FAVORITE_CHANGED) > 0) { + o.put("favorite", cursor.getInt(columnIsFavorite) == 1); + } + + if ((changeFlags & ReadingListItems.SYNC_CHANGE_UNREAD_CHANGED) > 0) { + final boolean isUnread = cursor.getInt(columnIsUnread) == 1; + o.put("unread", isUnread); + if (!isUnread) { + o.put("marked_read_by", cursor.getString(columnMarkedReadBy)); + o.put("marked_read_on", cursor.getLong(columnMarkedReadOn)); + } + } + + final ClientMetadata cm = null; + final ServerMetadata sm = new ServerMetadata(guid, -1L); + final ClientReadingListRecord record = new ClientReadingListRecord(sm, cm, o); + toUpload.add(record); + } + + return toUpload; + } finally { + cursor.close(); + } + } + + private Queue<ClientReadingListRecord> accumulateNewItems(Cursor cursor) { + try { + final Queue<ClientReadingListRecord> toUpload = new LinkedList<>(); + final ReadingListClientRecordFactory factory = new ReadingListClientRecordFactory(cursor); + + ClientReadingListRecord record; + while ((record = factory.getNext()) != null) { + toUpload.add(record); + } + return toUpload; + } finally { + cursor.close(); + } + } + + // N.B., status changes for items that haven't been uploaded yet are dealt with in + // uploadNewItems. + protected void uploadUnreadChanges(final StageDelegate delegate) { + try { + final Cursor cursor = local.getStatusChanges(); + + if (cursor == null) { + delegate.fail(new RuntimeException("Unable to get unread item cursor.")); + return; + } + + final Queue<ClientReadingListRecord> toUpload = collectStatusChangesFromCursor(cursor); + + // Nothing to do. + if (toUpload.isEmpty()) { + delegate.next(); + return; + } + + // Upload each record. This looks like batching, but it's really chained serial requests. + final ReadingListChangeAccumulator acc = this.local.getChangeAccumulator(); + final StatusUploadDelegate uploadDelegate = new StatusUploadDelegate(acc, delegate); + + // Don't send I-U-S; in the case of favorites we're + // happy to overwrite the server value, and in the case of unread status + // the server will reconcile for us. + this.remote.patch(toUpload, executor, uploadDelegate); + } catch (Exception e) { + delegate.fail(e); + } + } + + protected void uploadNewItems(final StageDelegate delegate) { + try { + final Cursor cursor = this.local.getNew(); + + if (cursor == null) { + delegate.fail(new RuntimeException("Unable to get new item cursor.")); + return; + } + + Queue<ClientReadingListRecord> toUpload = accumulateNewItems(cursor); + + // Nothing to do. + if (toUpload.isEmpty()) { + Logger.debug(LOG_TAG, "No new items to upload. Skipping."); + delegate.next(); + return; + } + + final ReadingListChangeAccumulator acc = this.local.getChangeAccumulator(); + final NewItemUploadDelegate uploadDelegate = new NewItemUploadDelegate(acc, new StageDelegate() { + private boolean tryFlushChanges() { + Logger.debug(LOG_TAG, "Flushing post-upload changes."); + try { + acc.finish(); + return true; + } catch (Exception e) { + Logger.warn(LOG_TAG, "Flushing changes failed! This sync went wrong.", e); + delegate.fail(e); + return false; + } + } + + @Override + public void next() { + Logger.debug(LOG_TAG, "New items uploaded successfully."); + + if (tryFlushChanges()) { + delegate.next(); + } + } + + @Override + public void fail() { + Logger.warn(LOG_TAG, "Couldn't upload new items."); + if (tryFlushChanges()) { + delegate.fail(); + } + } + + @Override + public void fail(Exception e) { + Logger.warn(LOG_TAG, "Couldn't upload new items.", e); + if (tryFlushChanges()) { + delegate.fail(e); + } + } + }); + + // Handle 201 for success, 400 for invalid, 303 for redirect. + // TODO: 200 == "was already on the server, we didn't touch it, here it is." + // ... we need to apply it locally. + this.remote.add(toUpload, executor, uploadDelegate); + } catch (Exception e) { + delegate.fail(e); + return; + } + } + + private void uploadModified(final StageDelegate delegate) { + // TODO + delegate.next(); + } + + private void downloadIncoming(final long since, final StageDelegate delegate) { + final ReadingListChangeAccumulator postDownload = this.local.getChangeAccumulator(); + + final FetchSpec spec = new FetchSpec.Builder().setSince(since).build(); + + // TODO: should we flush the accumulator if we get a failure? + ReadingListRecordDelegate recordDelegate = new ReadingListRecordDelegate() { + @Override + public void onRecordReceived(ServerReadingListRecord record) { + postDownload.addDownloadedRecord(record); + } + + @Override + public void onRecordMissingOrDeleted(String guid, ReadingListResponse resp) { + // Should never occur. Deleted records will be processed by onRecordReceived. + } + + @Override + public void onFailure(Exception error) { + Logger.error(LOG_TAG, "Download failed. since = " + since + ".", error); + delegate.fail(error); + } + + @Override + public void onFailure(MozResponse response) { + final int statusCode = response.getStatusCode(); + Logger.error(LOG_TAG, "Download failed. since = " + since + ". Response: " + statusCode); + response.logResponseBody(LOG_TAG); + delegate.fail(); + } + + @Override + public void onComplete(ReadingListResponse response) { + long lastModified = response.getLastModified(); + Logger.info(LOG_TAG, "Server last modified: " + lastModified); + try { + postDownload.finish(); + + // Yay. We do this here so that if writing changes fails, we don't advance. + advanceLastModified(lastModified); + delegate.next(); + } catch (Exception e) { + delegate.fail(e); + } + } + }; + + try { + remote.getAll(spec, recordDelegate, since); + } catch (URISyntaxException e) { + delegate.fail(e); + } + } + + /** + * Upload unread changes, then upload new items, then call `done`. + * Substantially modified records are uploaded last. + * + * @param syncDelegate only used for status callbacks. + */ + private void syncUp(final ReadingListSynchronizerDelegate syncDelegate, final StageDelegate done) { + // Second. + final StageDelegate onNewItemsUploaded = new NextDelegate(executor) { + @Override + public void doNext() { + syncDelegate.onNewItemUploadComplete(null, null); + done.next(); + } + + @Override + public void doFail(Exception e) { + done.fail(e); + } + }; + + // First. + final StageDelegate onUnreadChangesUploaded = new NextDelegate(executor) { + @Override + public void doNext() { + syncDelegate.onStatusUploadComplete(null, null); + uploadNewItems(onNewItemsUploaded); + } + + @Override + public void doFail(Exception e) { + Logger.warn(LOG_TAG, "Uploading unread changes failed.", e); + done.fail(e); + } + }; + + try { + uploadUnreadChanges(onUnreadChangesUploaded); + } catch (Exception ee) { + done.fail(ee); + } + } + + + /** + * Do an upload-only sync. + * By "upload-only" we mean status-only changes and new items. + * To upload modifications, use syncAll. + */ + /* + // Not yet used + public void syncUp(final ReadingListSynchronizerDelegate syncDelegate) { + final StageDelegate onUploadCompleted = new StageDelegate() { + @Override + public void next() { + // TODO + syncDelegate.onNewItemUploadComplete(null, null); + } + + @Override + public void fail(Exception e) { + syncDelegate.onUnableToSync(e); + } + }; + + executor.execute(new Runnable() { + @Override + public void run() { + try { + syncUp(onUploadCompleted); + } catch (Exception e) { + syncDelegate.onUnableToSync(e); + return; + } + } + }); + } +*/ + + /** + * Do a bidirectional sync. + */ + public void syncAll(final ReadingListSynchronizerDelegate syncDelegate) { + syncAll(getLastModified(), syncDelegate); + } + + public void syncAll(final long since, final ReadingListSynchronizerDelegate syncDelegate) { + // Fourth: call back to the synchronizer delegate. + final StageDelegate onModifiedUploadComplete = new NextDelegate(executor) { + @Override + public void doNext() { + syncDelegate.onModifiedUploadComplete(); + syncDelegate.onComplete(); + } + + @Override + public void doFail(Exception e) { + syncDelegate.onUnableToSync(e); + } + }; + + // Third: upload modified records. + final StageDelegate onDownloadCompleted = new NextDelegate(executor) { // TODO: since. + @Override + public void doNext() { + // TODO: save prefs. + syncDelegate.onDownloadComplete(); + uploadModified(onModifiedUploadComplete); + } + + @Override + public void doFail(Exception e) { + Logger.warn(LOG_TAG, "Download failed.", e); + syncDelegate.onUnableToSync(e); + } + }; + + // Second: download incoming changes. + final StageDelegate onUploadCompleted = new NextDelegate(executor) { + @Override + public void doNext() { + // N.B., we apply the downloaded versions of all uploaded records. + // That means the DB server timestamp matches the server's current + // timestamp when we do a fetch; we skip records in this way. + // We can also optimize by keeping the (guid, server timestamp) pair + // in memory, but of course this runs into invalidation issues if + // concurrent writes are occurring. + downloadIncoming(since, onDownloadCompleted); + } + + @Override + public void doFail(Exception e) { + Logger.warn(LOG_TAG, "Upload failed.", e); + syncDelegate.onUnableToSync(e); + } + }; + + // First: upload changes and new items. + executor.execute(new Runnable() { + @Override + public void run() { + try { + syncUp(syncDelegate, onUploadCompleted); + } catch (Exception e) { + syncDelegate.onUnableToSync(e); + return; + } + } + }); + + // TODO: ensure that records we identified as conflicts have been downloaded. + } + + protected long getLastModified() { + return prefs.getLong(PREF_LAST_MODIFIED, -1L); + } + + protected void advanceLastModified(final long lastModified) { + if (getLastModified() > lastModified) { + return; + } + prefs.edit().putLong(PREF_LAST_MODIFIED, lastModified).apply(); + } +}
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListSynchronizerDelegate.java @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import java.util.Collection; + +public interface ReadingListSynchronizerDelegate { + // Called on failure. + void onUnableToSync(Exception e); + + // These are called sequentially, or not at all + // if a failure occurs. + void onStatusUploadComplete(Collection<String> uploaded, Collection<String> failed); + void onNewItemUploadComplete(Collection<String> uploaded, Collection<String> failed); + void onDownloadComplete(); + void onModifiedUploadComplete(); + + // If no failure occurred, called at the end. + void onComplete(); +}
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ReadingListWipeDelegate.java @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import org.mozilla.gecko.sync.net.MozResponse; + +public interface ReadingListWipeDelegate { + void onSuccess(ReadingListStorageResponse response); + void onPreconditionFailed(MozResponse response); + void onFailure(Exception e); + void onFailure(MozResponse response); +}
new file mode 100644 --- /dev/null +++ b/mobile/android/base/reading/ServerReadingListRecord.java @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.reading; + +import org.mozilla.gecko.sync.ExtendedJSONObject; + +public class ServerReadingListRecord extends ReadingListRecord { + final ExtendedJSONObject fields; + + public ServerReadingListRecord(ExtendedJSONObject obj) { + super(new ServerMetadata(obj)); + this.fields = obj.deepCopy(); + } + + @Override + public String getURL() { + return this.fields.getString("url"); // TODO: resolved_url + } + + @Override + public String getTitle() { + return this.fields.getString("title"); // TODO: resolved_title + } + + @Override + public String getAddedBy() { + return this.fields.getString("added_by"); + } +} \ No newline at end of file
--- a/mobile/android/base/sync/net/MozResponse.java +++ b/mobile/android/base/sync/net/MozResponse.java @@ -173,9 +173,20 @@ public class MozResponse { /** * @return A number of seconds, or -1 if the 'X-Backoff' header was not * present. */ public int backoffInSeconds() throws NumberFormatException { return this.getIntegerHeader("x-backoff"); } + + public void logResponseBody(final String logTag) { + if (!Logger.LOG_PERSONAL_INFORMATION) { + return; + } + try { + Logger.pii(logTag, "Response body: " + body()); + } catch (Throwable e) { + Logger.debug(logTag, "No response body."); + } + } }
--- a/mobile/android/chrome/content/aboutAddons.js +++ b/mobile/android/chrome/content/aboutAddons.js @@ -521,16 +521,29 @@ var Addons = { element.setAttribute("opType", "needs-restart"); }, onInstalled: function(aAddon) { let list = document.getElementById("addons-list"); let element = this._getElementForAddon(aAddon.id); if (!element) { element = this._createItemForAddon(aAddon); + + // Themes aren't considered active on install, so set existing as disabled, and new one enabled. + if (aAddon.type == "theme") { + let item = list.firstElementChild; + while (item) { + if (item.addon && (item.addon.type == "theme")) { + item.setAttribute("isDisabled", true); + } + item = item.nextSibling; + } + element.setAttribute("isDisabled", false); + } + list.insertBefore(element, list.firstElementChild); } }, onUninstalled: function(aAddon) { let list = document.getElementById("addons-list"); let element = this._getElementForAddon(aAddon.id); list.removeChild(element);
--- a/mobile/android/modules/DownloadNotifications.jsm +++ b/mobile/android/modules/DownloadNotifications.jsm @@ -11,16 +11,19 @@ const { classes: Cc, interfaces: Ci, uti Cu.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Downloads", "resource://gre/modules/Downloads.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", "resource://gre/modules/FileUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Notifications", "resource://gre/modules/Notifications.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Services", "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "ParentalControls", + "@mozilla.org/parental-controls-service;1", "nsIParentalControlsService"); + let Log = Cu.import("resource://gre/modules/AndroidLog.jsm", {}).AndroidLog.i.bind(null, "DownloadNotifications"); XPCOMUtils.defineLazyGetter(this, "strings", () => Services.strings.createBundle("chrome://browser/locale/browser.properties")); Object.defineProperty(this, "window", { get: () => Services.wm.getMostRecentWindow("navigator:browser") }); @@ -53,16 +56,23 @@ var DownloadNotifications = { onDownloadAdded: function (download) { // Don't create notifications for pre-existing succeeded downloads. // We still add notifications for canceled downloads in case the // user decides to retry the download. if (download.succeeded && !this._viewAdded) { return; } + if (!ParentalControls.isAllowed(ParentalControls.DOWNLOAD)) { + download.cancel().catch(Cu.reportError); + download.removePartialData().catch(Cu.reportError); + window.NativeWindow.toast.show(strings.GetStringFromName("downloads.disabledInGuest"), "long"); + return; + } + let notification = new DownloadNotification(download); notifications.set(download, notification); notification.showOrUpdate(); // If this is a new download, show a toast as well. if (this._viewAdded) { window.NativeWindow.toast.show(strings.GetStringFromName("alertDownloadsToast"), "long"); }
--- a/mobile/android/themes/core/aboutReader.css +++ b/mobile/android/themes/core/aboutReader.css @@ -275,37 +275,31 @@ body { .content ol { -moz-padding-start: 35px !important; list-style: decimal !important; } /*======= Controls toolbar =======*/ .toolbar { - font-family: "Clear Sans",sans-serif; - transition-property: visibility, opacity; - transition-duration: 0.7s; - visibility: visible; - opacity: 1.0; + font-family: sans-serif; + transition-property: bottom; + transition-duration: 0.3s; position: fixed; width: 100%; - bottom: 0px; - left: 0px; + left: 0; margin: 0; padding: 0; list-style: none; background-color: #EBEBF0; border-top: 1px solid #D7D9DB; } -.toolbar-hidden { - transition-property: visibility, opacity; - transition-duration: 0.7s; - visibility: hidden; - opacity: 0.0; +.toolbar[visible] { + bottom: 0; } .toolbar > * { float: right; width: 33%; } .button { @@ -585,22 +579,30 @@ body { background-image: url('chrome://browser/skin/images/reader-plus-xxhdpi.png'); } } @media screen and (orientation: portrait) { .button { height: 56px; } + + .toolbar { + bottom: -57px; + } } @media screen and (orientation: landscape) { .button { height: 40px; } + + .toolbar { + bottom: -41px; + } } @media screen and (min-width: 960px) { .button { width: 56px; height: 56px; }
--- a/testing/docker/builder/Dockerfile +++ b/testing/docker/builder/Dockerfile @@ -16,16 +16,16 @@ RUN hg clone http://hg.mozilla.org/build cd /tools/tools && \ python setup.py install # Initialize git (makes repo happy) RUN git config --global user.email "mozilla@example.com" && \ git config --global user.name "mozilla" # VCS Tools -RUN npm install -g taskcluster-vcs@2.3.0 +RUN npm install -g taskcluster-vcs@2.3.1 # TODO enable worker # TODO volume mount permissions will be an issue # USER worker COPY bin /home/worker/bin RUN chmod a+x /home/worker/bin/*
--- a/testing/docker/builder/VERSION +++ b/testing/docker/builder/VERSION @@ -1,1 +1,1 @@ -0.5.1 +0.5.2
--- a/testing/docker/decision/Dockerfile +++ b/testing/docker/decision/Dockerfile @@ -1,8 +1,8 @@ FROM quay.io/mozilla/base-build:0.0.1 MAINTAINER Jonas Finnemann Jensen <jopsen@gmail.com> ENV PATH /home/worker/bin/:$PATH # Add utilities and configuration -RUN npm install -g taskcluster-vcs@0.0.2 +RUN npm install -g taskcluster-vcs@2.3.1 ADD bin /home/worker/bin
--- a/testing/docker/decision/VERSION +++ b/testing/docker/decision/VERSION @@ -1,1 +1,1 @@ -0.0.3 +0.0.4
--- a/testing/docker/phone-builder/Dockerfile +++ b/testing/docker/phone-builder/Dockerfile @@ -1,9 +1,9 @@ -FROM quay.io/mozilla/builder:0.5.0 +FROM quay.io/mozilla/builder:0.5.2 MAINTAINER Wander Lairson Costa <wcosta@mozilla.com> # Add utilities and configuration ADD bin /home/worker/bin ADD config /home/worker/.aws/config ADD system-setup.sh /tmp/system-setup.sh RUN /tmp/system-setup.sh
--- a/testing/docker/phone-builder/VERSION +++ b/testing/docker/phone-builder/VERSION @@ -1,1 +1,1 @@ -0.0.9 +0.0.10
--- a/testing/docker/tester/Dockerfile +++ b/testing/docker/tester/Dockerfile @@ -11,16 +11,16 @@ COPY buildprops.json ADD https://raw.githubusercontent.com/taskcluster/buildbot-step/master/buildbot_step /home/worker/bin/buildbot_step # Run test setup script RUN chmod u+x /home/worker/bin/buildbot_step RUN pip install virtualenv; RUN mkdir Documents; mkdir Pictures; mkdir Music; mkdir Videos; mkdir artifacts RUN chown -R worker:worker /home/worker/* /home/worker/.* -RUN npm install -g taskcluster-vcs@2.2.0 +RUN npm install -g taskcluster-vcs@2.3.1 ENV PATH $PATH:/home/worker/bin # TODO Re-enable worker when bug 1093833 lands #USER worker # Set a default command useful for debugging CMD ["/bin/bash", "--login"]
--- a/testing/docker/tester/VERSION +++ b/testing/docker/tester/VERSION @@ -1,1 +1,1 @@ -0.0.14 +0.0.15
--- a/testing/taskcluster/mach_commands.py +++ b/testing/taskcluster/mach_commands.py @@ -203,17 +203,17 @@ class Graph(object): help='URL for "head" repository to fetch revision from') @CommandArgument('--head-ref', default=os.environ.get('GECKO_HEAD_REF'), help='Reference (this is same as rev usually for hg)') @CommandArgument('--head-rev', default=os.environ.get('GECKO_HEAD_REV'), help='Commit revision to use from head repository') @CommandArgument('--mozharness-rev', - default='emulator-perf', + default='default', help='Commit revision to use from mozharness repository') @CommandArgument('--message', help='Commit message to be parsed. Example: "try: -b do -p all -u all"') @CommandArgument('--revision-hash', required=False, help='Treeherder revision hash to attach results to') @CommandArgument('--project', required=True,
--- a/toolkit/components/reader/AboutReader.jsm +++ b/toolkit/components/reader/AboutReader.jsm @@ -40,18 +40,16 @@ let AboutReader = function(mm, win) { this._headerElementRef = Cu.getWeakReference(doc.getElementById("reader-header")); this._domainElementRef = Cu.getWeakReference(doc.getElementById("reader-domain")); this._titleElementRef = Cu.getWeakReference(doc.getElementById("reader-title")); this._creditsElementRef = Cu.getWeakReference(doc.getElementById("reader-credits")); this._contentElementRef = Cu.getWeakReference(doc.getElementById("reader-content")); this._toolbarElementRef = Cu.getWeakReference(doc.getElementById("reader-toolbar")); this._messageElementRef = Cu.getWeakReference(doc.getElementById("reader-message")); - this._toolbarEnabled = false; - this._scrollOffset = win.pageYOffset; doc.getElementById("container").addEventListener("click", this, false); win.addEventListener("unload", this, false); win.addEventListener("scroll", this, false); win.addEventListener("resize", this, false); @@ -251,17 +249,18 @@ AboutReader.prototype = { if (args.url == this._article.url) { if (this._isReadingListItem != args.inReadingList) { let isInitialStateChange = (this._isReadingListItem == -1); this._isReadingListItem = args.inReadingList; this._updateToggleButton(); // Display the toolbar when all its initial component states are known if (isInitialStateChange) { - this._setToolbarVisibility(true); + // Hacks! Delay showing the toolbar to avoid position: fixed; jankiness. See bug 975533. + this._win.setTimeout(() => this._setToolbarVisibility(true), 500); } } } }; this._mm.addMessageListener("Reader:ListStatusData", handleListStatusData); this._mm.sendAsyncMessage("Reader:ListStatusRequest", { url: this._article.url }); }, @@ -519,34 +518,32 @@ AboutReader.prototype = { this._mm.sendAsyncMessage("Reader:SetCharPref", { name: "reader.font_type", value: this._fontType }); }, _getToolbarVisibility: function Reader_getToolbarVisibility() { - return !this._toolbarElement.classList.contains("toolbar-hidden"); + return this._toolbarElement.hasAttribute("visible"); }, _setToolbarVisibility: function Reader_setToolbarVisibility(visible) { let dropdown = this._doc.getElementById("style-dropdown"); dropdown.classList.remove("open"); - if (!this._toolbarEnabled) - return; - - // Don't allow visible toolbar until banner state is known - if (this._isReadingListItem == -1) + if (this._getToolbarVisibility() === visible) { return; + } - if (this._getToolbarVisibility() === visible) - return; - - this._toolbarElement.classList.toggle("toolbar-hidden"); + if (visible) { + this._toolbarElement.setAttribute("visible", true); + } else { + this._toolbarElement.removeAttribute("visible"); + } this._setSystemUIVisibility(visible); if (!visible) { this._mm.sendAsyncMessage("Reader:ToolbarHidden"); } }, _toggleToolbarVisibility: function Reader_toggleToolbarVisibility() { @@ -706,19 +703,16 @@ AboutReader.prototype = { this._contentElement.innerHTML = ""; this._contentElement.appendChild(contentFragment); this._maybeSetTextDirection(article); this._contentElement.style.display = "block"; this._updateImageMargins(); this._requestReadingListStatus(); - this._toolbarEnabled = true; - this._setToolbarVisibility(true); - this._requestFavicon(); }, _hideContent: function Reader_hideContent() { this._headerElement.style.display = "none"; this._contentElement.style.display = "none"; },
--- a/toolkit/components/reader/ReaderMode.jsm +++ b/toolkit/components/reader/ReaderMode.jsm @@ -214,17 +214,22 @@ this.ReaderMode = { scheme: uri.scheme, pathBase: Services.io.newURI(".", null, uri).spec }; let serializer = Cc["@mozilla.org/xmlextras/xmlserializer;1"]. createInstance(Ci.nsIDOMSerializer); let serializedDoc = yield Promise.resolve(serializer.serializeToString(doc)); - let article = yield ReaderWorker.post("parseDocument", [uriParam, serializedDoc]); + let article = null; + try { + article = yield ReaderWorker.post("parseDocument", [uriParam, serializedDoc]); + } catch (e) { + Cu.reportError("Error in ReaderWorker: " + e); + } if (!article) { this.log("Worker did not return an article"); return null; } // Readability returns a URI object, but we only care about the URL. article.url = article.uri.spec;
--- a/toolkit/components/reader/content/aboutReader.html +++ b/toolkit/components/reader/content/aboutReader.html @@ -21,17 +21,17 @@ <div id="reader-content" class="content"> </div> <div id="reader-message" class="message"> </div> </div> - <ul id="reader-toolbar" class="toolbar toolbar-hidden"> + <ul id="reader-toolbar" class="toolbar"> <li><button id="close-button" class="button close-button"/></li> <li><button id="share-button" class="button share-button"/></li> <ul id="style-dropdown" class="dropdown"> <li><button class="dropdown-toggle button style-button"/></li> <li class="dropdown-popup"> <div id="font-type-buttons"></div> <hr></hr> <div id="font-size-buttons">
--- a/toolkit/content/widgets/browser.xml +++ b/toolkit/content/widgets/browser.xml @@ -383,19 +383,18 @@ this._fastFind.init(this.docShell); } return this._fastFind; ]]></getter> </property> <field name="_permanentKey">({})</field> - <property name="permanentKey" - onget="return this._permanentKey;" - onset="this._permanentKey = val;"/> + <property name="permanentKey" readonly="true" + onget="return this._permanentKey;"/> <property name="outerWindowID" readonly="true"> <getter><![CDATA[ return this.contentWindow .QueryInterface(Components.interfaces.nsIInterfaceRequestor) .getInterface(Components.interfaces.nsIDOMWindowUtils) .outerWindowID; ]]></getter>
--- a/toolkit/devtools/security/auth.js +++ b/toolkit/devtools/security/auth.js @@ -10,16 +10,18 @@ let { Ci, Cc } = require("chrome"); let Services = require("Services"); let promise = require("promise"); let DevToolsUtils = require("devtools/toolkit/DevToolsUtils"); let { dumpn, dumpv } = DevToolsUtils; loader.lazyRequireGetter(this, "prompt", "devtools/toolkit/security/prompt"); loader.lazyRequireGetter(this, "cert", "devtools/toolkit/security/cert"); +loader.lazyRequireGetter(this, "asyncStorage", + "devtools/toolkit/shared/async-storage"); DevToolsUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm"); /** * A simple enum-like object with keys mirrored to values. * This makes comparison to a specfic value simpler without having to repeat and * mis-type the value. */ @@ -336,28 +338,34 @@ OOBCert.Client.prototype = { host, port, cert, authResult, oob: oobData }); break; case AuthenticationResult.ALLOW: - case AuthenticationResult.ALLOW_PERSIST: // Step B.12 // Client verifies received value matches K if (packet.k != oobData.k) { transport.close(new Error("Auth secret mismatch")); return; } // Step B.13 // Debugging begins transport.hooks = null; deferred.resolve(transport); break; + case AuthenticationResult.ALLOW_PERSIST: + // Server previously persisted Client as allowed + // Step C.5 + // Debugging begins + transport.hooks = null; + deferred.resolve(transport); + break; default: transport.close(new Error("Invalid auth result: " + authResult)); return; } }.bind(this)), onClosed(reason) { closeDialog(); // Transport died before auth completed @@ -483,40 +491,50 @@ OOBCert.Server.prototype = { * transport * } * @return An AuthenticationResult value. * A promise that will be resolved to the above is also allowed. */ authenticate: Task.async(function*({ client, server, transport }) { // Step B.3 / C.3 // TLS connection established, authentication begins - // TODO: Bug 1032128: Consult a list of persisted, approved clients + const storageKey = `devtools.auth.${this.mode}.approved-clients`; + let approvedClients = (yield asyncStorage.getItem(storageKey)) || {}; + // Step C.4 + // Server sees that ClientCert is from a known client via hash(ClientCert) + if (approvedClients[client.cert.sha256]) { + let authResult = AuthenticationResult.ALLOW_PERSIST; + transport.send({ authResult }); + // Step C.5 + // Debugging begins + return authResult; + } + // Step B.4 // Server sees that ClientCert is from a unknown client // Tell client they are unknown and should display OOB client UX transport.send({ authResult: AuthenticationResult.PENDING }); // Step B.5 // User is shown a Allow / Deny / Always Allow prompt on the Server // with Client name and hash(ClientCert) - let result = yield this.allowConnection({ + let authResult = yield this.allowConnection({ authentication: this.mode, client, server }); - switch (result) { + switch (authResult) { case AuthenticationResult.ALLOW_PERSIST: - // TODO: Bug 1032128: Persist the client case AuthenticationResult.ALLOW: break; // Further processing default: - return result; // Abort for any negative results + return authResult; // Abort for any negative results } // Examine additional data for authentication let oob = yield this.receiveOOB(); if (!oob) { dumpn("Invalid OOB data received"); return AuthenticationResult.DENY; } @@ -535,24 +553,30 @@ OOBCert.Server.prototype = { // out-of-band channel if (client.cert.sha256 != sha256) { dumpn("Client cert hash doesn't match OOB data"); return AuthenticationResult.DENY; } // Step B.11 // Server sends K to Client over TLS connection - transport.send({ authResult: result, k }); + transport.send({ authResult, k }); + + // Persist Client if we want to always allow in the future + if (authResult === AuthenticationResult.ALLOW_PERSIST) { + approvedClients[client.cert.sha256] = true; + yield asyncStorage.setItem(storageKey, approvedClients); + } // Client may decide to abort if K does not match. // Server's portion of authentication is now complete. // Step B.13 // Debugging begins - return result; + return authResult; }), /** * Prompt the user to accept or decline the incoming connection. The default * implementation is used unless this is overridden on a particular * authenticator instance. * * It is expected that the implementation of |allowConnection| will show a
--- a/toolkit/devtools/server/actors/script.js +++ b/toolkit/devtools/server/actors/script.js @@ -2588,17 +2588,17 @@ SourceActor.prototype = { * source map from b to a. We need to do this because the source map we get * from _generatePrettyCodeAndMap goes the opposite way we want it to for * debugging. * * Note that the source map is modified in place. */ _invertSourceMap: function ({ code, mappings }) { const generator = new SourceMapGenerator({ file: this.url }); - return DevToolsUtils.yieldingEach(mappings, m => { + return DevToolsUtils.yieldingEach(mappings._array, m => { let mapping = { generated: { line: m.generatedLine, column: m.generatedColumn } }; if (m.source) { mapping.source = m.source; @@ -5714,17 +5714,18 @@ ThreadSources.prototype = { } = originalLocation; let { line: generatedLine, column: generatedColumn } = map.generatedPositionFor({ source: originalSourceActor.url, line: originalLine, - column: originalColumn == null ? Infinity : originalColumn + column: originalColumn == null ? 0 : originalColumn, + bias: SourceMapConsumer.LEAST_UPPER_BOUND }); return new GeneratedLocation( this.createNonSourceMappedActor(source), generatedLine, generatedColumn ); }
--- a/toolkit/devtools/shared/tests/browser/browser_async_storage.js +++ b/toolkit/devtools/shared/tests/browser/browser_async_storage.js @@ -4,74 +4,74 @@ "use strict"; // Test the basic functionality of async-storage. // Adapted from https://github.com/mozilla-b2g/gaia/blob/f09993563fb5fec4393eb71816ce76cb00463190/apps/sharedtest/test/unit/async_storage_test.js. const asyncStorage = require("devtools/toolkit/shared/async-storage"); add_task(function*() { - is(typeof asyncStorage.length, 'function', "API exists."); - is(typeof asyncStorage.key, 'function', "API exists."); - is(typeof asyncStorage.getItem, 'function', "API exists."); - is(typeof asyncStorage.setItem, 'function', "API exists."); - is(typeof asyncStorage.removeItem, 'function', "API exists."); - is(typeof asyncStorage.clear, 'function', "API exists."); + is(typeof asyncStorage.length, "function", "API exists."); + is(typeof asyncStorage.key, "function", "API exists."); + is(typeof asyncStorage.getItem, "function", "API exists."); + is(typeof asyncStorage.setItem, "function", "API exists."); + is(typeof asyncStorage.removeItem, "function", "API exists."); + is(typeof asyncStorage.clear, "function", "API exists."); }); add_task(function*() { - yield asyncStorage.setItem('foo', 'bar'); - let value = yield asyncStorage.getItem('foo'); - is(value, 'bar', 'value is correct'); - yield asyncStorage.setItem('foo', 'overwritten'); - value = yield asyncStorage.getItem('foo'); - is(value, 'overwritten', 'value is correct'); - yield asyncStorage.removeItem('foo'); - value = yield asyncStorage.getItem('foo'); - is(value, null, 'value is correct'); + yield asyncStorage.setItem("foo", "bar"); + let value = yield asyncStorage.getItem("foo"); + is(value, "bar", "value is correct"); + yield asyncStorage.setItem("foo", "overwritten"); + value = yield asyncStorage.getItem("foo"); + is(value, "overwritten", "value is correct"); + yield asyncStorage.removeItem("foo"); + value = yield asyncStorage.getItem("foo"); + is(value, null, "value is correct"); }); add_task(function*() { var object = { x: 1, - y: 'foo', + y: "foo", z: true }; - yield asyncStorage.setItem('myobj', object); - let value = yield asyncStorage.getItem('myobj'); - is(object.x, value.x, 'value is correct'); - is(object.y, value.y, 'value is correct'); - is(object.z, value.z, 'value is correct'); - yield asyncStorage.removeItem('myobj'); - value = yield asyncStorage.getItem('myobj'); - is(value, null, 'value is correct'); + yield asyncStorage.setItem("myobj", object); + let value = yield asyncStorage.getItem("myobj"); + is(object.x, value.x, "value is correct"); + is(object.y, value.y, "value is correct"); + is(object.z, value.z, "value is correct"); + yield asyncStorage.removeItem("myobj"); + value = yield asyncStorage.getItem("myobj"); + is(value, null, "value is correct"); }); add_task(function*() { yield asyncStorage.clear(); let len = yield asyncStorage.length(); - is(len, 0, 'length is correct'); - yield asyncStorage.setItem('key1', 'value1'); + is(len, 0, "length is correct"); + yield asyncStorage.setItem("key1", "value1"); len = yield asyncStorage.length(); - is(len, 1, 'length is correct'); - yield asyncStorage.setItem('key2', 'value2'); + is(len, 1, "length is correct"); + yield asyncStorage.setItem("key2", "value2"); len = yield asyncStorage.length(); - is(len, 2, 'length is correct'); - yield asyncStorage.setItem('key3', 'value3'); + is(len, 2, "length is correct"); + yield asyncStorage.setItem("key3", "value3"); len = yield asyncStorage.length(); - is(len, 3, 'length is correct'); + is(len, 3, "length is correct"); let key = yield asyncStorage.key(0); - is(key, 'key1', 'key is correct'); + is(key, "key1", "key is correct"); key = yield asyncStorage.key(1); - is(key, 'key2', 'key is correct'); + is(key, "key2", "key is correct"); key = yield asyncStorage.key(2); - is(key, 'key3', 'key is correct'); + is(key, "key3", "key is correct"); key = yield asyncStorage.key(3); - is(key, null, 'key is correct'); + is(key, null, "key is correct"); yield asyncStorage.clear(); key = yield asyncStorage.key(0); - is(key, null, 'key is correct'); + is(key, null, "key is correct"); len = yield asyncStorage.length(); - is(len, 0, 'length is correct'); + is(len, 0, "length is correct"); });
--- a/toolkit/devtools/sourcemap/SourceMap.jsm +++ b/toolkit/devtools/sourcemap/SourceMap.jsm @@ -26,128 +26,36 @@ Components.utils.import('resource://gre/ */ define('source-map/source-map-consumer', ['require', 'exports', 'module' , 'source-map/util', 'source-map/binary-search', 'source-map/array-set', 'source-map/base64-vlq'], function(require, exports, module) { var util = require('source-map/util'); var binarySearch = require('source-map/binary-search'); var ArraySet = require('source-map/array-set').ArraySet; var base64VLQ = require('source-map/base64-vlq'); - /** - * A SourceMapConsumer instance represents a parsed source map which we can - * query for information about the original file positions by giving it a file - * position in the generated source. - * - * The only parameter is the raw source map (either as a JSON string, or - * already parsed to an object). According to the spec, source maps have the - * following attributes: - * - * - version: Which version of the source map spec this map is following. - * - sources: An array of URLs to the original source files. - * - names: An array of identifiers which can be referrenced by individual mappings. - * - sourceRoot: Optional. The URL root from which all sources are relative. - * - sourcesContent: Optional. An array of contents of the original source files. - * - mappings: A string of base64 VLQs which contain the actual mappings. - * - file: Optional. The generated file this source map is associated with. - * - * Here is an example source map, taken from the source map spec[0]: - * - * { - * version : 3, - * file: "out.js", - * sourceRoot : "", - * sources: ["foo.js", "bar.js"], - * names: ["src", "maps", "are", "fun"], - * mappings: "AA,AB;;ABCDE;" - * } - * - * [0]: https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit?pli=1# - */ function SourceMapConsumer(aSourceMap) { var sourceMap = aSourceMap; if (typeof aSourceMap === 'string') { sourceMap = JSON.parse(aSourceMap.replace(/^\)\]\}'/, '')); } - var version = util.getArg(sourceMap, 'version'); - var sources = util.getArg(sourceMap, 'sources'); - // Sass 3.3 leaves out the 'names' array, so we deviate from the spec (which - // requires the array) to play nice here. - var names = util.getArg(sourceMap, 'names', []); - var sourceRoot = util.getArg(sourceMap, 'sourceRoot', null); - var sourcesContent = util.getArg(sourceMap, 'sourcesContent', null); - var mappings = util.getArg(sourceMap, 'mappings'); - var file = util.getArg(sourceMap, 'file', null); - - // Once again, Sass deviates from the spec and supplies the version as a - // string rather than a number, so we use loose equality checking here. - if (version != this._version) { - throw new Error('Unsupported version: ' + version); - } - - // Some source maps produce relative source paths like "./foo.js" instead of - // "foo.js". Normalize these first so that future comparisons will succeed. - // See bugzil.la/1090768. - sources = sources.map(util.normalize); - - // Pass `true` below to allow duplicate names and sources. While source maps - // are intended to be compressed and deduplicated, the TypeScript compiler - // sometimes generates source maps with duplicates in them. See Github issue - // #72 and bugzil.la/889492. - this._names = ArraySet.fromArray(names, true); - this._sources = ArraySet.fromArray(sources, true); - - this.sourceRoot = sourceRoot; - this.sourcesContent = sourcesContent; - this._mappings = mappings; - this.file = file; + return sourceMap.sections != null + ? new IndexedSourceMapConsumer(sourceMap) + : new BasicSourceMapConsumer(sourceMap); } - /** - * Create a SourceMapConsumer from a SourceMapGenerator. - * - * @param SourceMapGenerator aSourceMap - * The source map that will be consumed. - * @returns SourceMapConsumer - */ - SourceMapConsumer.fromSourceMap = - function SourceMapConsumer_fromSourceMap(aSourceMap) { - var smc = Object.create(SourceMapConsumer.prototype); - - smc._names = ArraySet.fromArray(aSourceMap._names.toArray(), true); - smc._sources = ArraySet.fromArray(aSourceMap._sources.toArray(), true); - smc.sourceRoot = aSourceMap._sourceRoot; - smc.sourcesContent = aSourceMap._generateSourcesContent(smc._sources.toArray(), - smc.sourceRoot); - smc.file = aSourceMap._file; - - smc.__generatedMappings = aSourceMap._mappings.slice() - .sort(util.compareByGeneratedPositions); - smc.__originalMappings = aSourceMap._mappings.slice() - .sort(util.compareByOriginalPositions); - - return smc; - }; + SourceMapConsumer.fromSourceMap = function(aSourceMap) { + return BasicSourceMapConsumer.fromSourceMap(aSourceMap); + } /** * The version of the source mapping spec that we are consuming. */ SourceMapConsumer.prototype._version = 3; - /** - * The list of original sources. - */ - Object.defineProperty(SourceMapConsumer.prototype, 'sources', { - get: function () { - return this._sources.toArray().map(function (s) { - return this.sourceRoot != null ? util.join(this.sourceRoot, s) : s; - }, this); - } - }); - // `__generatedMappings` and `__originalMappings` are arrays that hold the // parsed mapping coordinates from the source map's "mappings" attribute. They // are lazily instantiated, accessed via the `_generatedMappings` and // `_originalMappings` getters respectively, and we only parse the mappings // and create these arrays once queried for a source location. We jump through // these hoops because there can be many thousands of mappings, and parsing // them is expensive, so we only want to do it if we must. // @@ -195,354 +103,37 @@ define('source-map/source-map-consumer', this._parseMappings(this._mappings, this.sourceRoot); } return this.__originalMappings; } }); SourceMapConsumer.prototype._nextCharIsMappingSeparator = - function SourceMapConsumer_nextCharIsMappingSeparator(aStr) { - var c = aStr.charAt(0); + function SourceMapConsumer_nextCharIsMappingSeparator(aStr, index) { + var c = aStr.charAt(index); return c === ";" || c === ","; }; /** * Parse the mappings in a string in to a data structure which we can easily * query (the ordered arrays in the `this.__generatedMappings` and * `this.__originalMappings` properties). */ SourceMapConsumer.prototype._parseMappings = function SourceMapConsumer_parseMappings(aStr, aSourceRoot) { - var generatedLine = 1; - var previousGeneratedColumn = 0; - var previousOriginalLine = 0; - var previousOriginalColumn = 0; - var previousSource = 0; - var previousName = 0; - var str = aStr; - var temp = {}; - var mapping; - - while (str.length > 0) { - if (str.charAt(0) === ';') { - generatedLine++; - str = str.slice(1); - previousGeneratedColumn = 0; - } - else if (str.charAt(0) === ',') { - str = str.slice(1); - } - else { - mapping = {}; - mapping.generatedLine = generatedLine; - - // Generated column. - base64VLQ.decode(str, temp); - mapping.generatedColumn = previousGeneratedColumn + temp.value; - previousGeneratedColumn = mapping.generatedColumn; - str = temp.rest; - - if (str.length > 0 && !this._nextCharIsMappingSeparator(str)) { - // Original source. - base64VLQ.decode(str, temp); - mapping.source = this._sources.at(previousSource + temp.value); - previousSource += temp.value; - str = temp.rest; - if (str.length === 0 || this._nextCharIsMappingSeparator(str)) { - throw new Error('Found a source, but no line and column'); - } - - // Original line. - base64VLQ.decode(str, temp); - mapping.originalLine = previousOriginalLine + temp.value; - previousOriginalLine = mapping.originalLine; - // Lines are stored 0-based - mapping.originalLine += 1; - str = temp.rest; - if (str.length === 0 || this._nextCharIsMappingSeparator(str)) { - throw new Error('Found a source and line, but no column'); - } - - // Original column. - base64VLQ.decode(str, temp); - mapping.originalColumn = previousOriginalColumn + temp.value; - previousOriginalColumn = mapping.originalColumn; - str = temp.rest; - - if (str.length > 0 && !this._nextCharIsMappingSeparator(str)) { - // Original name. - base64VLQ.decode(str, temp); - mapping.name = this._names.at(previousName + temp.value); - previousName += temp.value; - str = temp.rest; - } - } - - this.__generatedMappings.push(mapping); - if (typeof mapping.originalLine === 'number') { - this.__originalMappings.push(mapping); - } - } - } - - this.__generatedMappings.sort(util.compareByGeneratedPositions); - this.__originalMappings.sort(util.compareByOriginalPositions); - }; - - /** - * Find the mapping that best matches the hypothetical "needle" mapping that - * we are searching for in the given "haystack" of mappings. - */ - SourceMapConsumer.prototype._findMapping = - function SourceMapConsumer_findMapping(aNeedle, aMappings, aLineName, - aColumnName, aComparator) { - // To return the position we are searching for, we must first find the - // mapping for the given position and then return the opposite position it - // points to. Because the mappings are sorted, we can use binary search to - // find the best mapping. - - if (aNeedle[aLineName] <= 0) { - throw new TypeError('Line must be greater than or equal to 1, got ' - + aNeedle[aLineName]); - } - if (aNeedle[aColumnName] < 0) { - throw new TypeError('Column must be greater than or equal to 0, got ' - + aNeedle[aColumnName]); - } - - return binarySearch.search(aNeedle, aMappings, aComparator); - }; - - /** - * Compute the last column for each generated mapping. The last column is - * inclusive. - */ - SourceMapConsumer.prototype.computeColumnSpans = - function SourceMapConsumer_computeColumnSpans() { - for (var index = 0; index < this._generatedMappings.length; ++index) { - var mapping = this._generatedMappings[index]; - - // Mappings do not contain a field for the last generated columnt. We - // can come up with an optimistic estimate, however, by assuming that - // mappings are contiguous (i.e. given two consecutive mappings, the - // first mapping ends where the second one starts). - if (index + 1 < this._generatedMappings.length) { - var nextMapping = this._generatedMappings[index + 1]; - - if (mapping.generatedLine === nextMapping.generatedLine) { - mapping.lastGeneratedColumn = nextMapping.generatedColumn - 1; - continue; - } - } - - // The last mapping for each line spans the entire line. - mapping.lastGeneratedColumn = Infinity; - } - }; - - /** - * Returns the original source, line, and column information for the generated - * source's line and column positions provided. The only argument is an object - * with the following properties: - * - * - line: The line number in the generated source. - * - column: The column number in the generated source. - * - * and an object is returned with the following properties: - * - * - source: The original source file, or null. - * - line: The line number in the original source, or null. - * - column: The column number in the original source, or null. - * - name: The original identifier, or null. - */ - SourceMapConsumer.prototype.originalPositionFor = - function SourceMapConsumer_originalPositionFor(aArgs) { - var needle = { - generatedLine: util.getArg(aArgs, 'line'), - generatedColumn: util.getArg(aArgs, 'column') - }; - - var index = this._findMapping(needle, - this._generatedMappings, - "generatedLine", - "generatedColumn", - util.compareByGeneratedPositions); - - if (index >= 0) { - var mapping = this._generatedMappings[index]; - - if (mapping.generatedLine === needle.generatedLine) { - var source = util.getArg(mapping, 'source', null); - if (source != null && this.sourceRoot != null) { - source = util.join(this.sourceRoot, source); - } - return { - source: source, - line: util.getArg(mapping, 'originalLine', null), - column: util.getArg(mapping, 'originalColumn', null), - name: util.getArg(mapping, 'name', null) - }; - } - } - - return { - source: null, - line: null, - column: null, - name: null - }; - }; - - /** - * Returns the original source content. The only argument is the url of the - * original source file. Returns null if no original source content is - * availible. - */ - SourceMapConsumer.prototype.sourceContentFor = - function SourceMapConsumer_sourceContentFor(aSource) { - if (!this.sourcesContent) { - return null; - } - - if (this.sourceRoot != null) { - aSource = util.relative(this.sourceRoot, aSource); - } - - if (this._sources.has(aSource)) { - return this.sourcesContent[this._sources.indexOf(aSource)]; - } - - var url; - if (this.sourceRoot != null - && (url = util.urlParse(this.sourceRoot))) { - // XXX: file:// URIs and absolute paths lead to unexpected behavior for - // many users. We can help them out when they expect file:// URIs to - // behave like it would if they were running a local HTTP server. See - // https://bugzilla.mozilla.org/show_bug.cgi?id=885597. - var fileUriAbsPath = aSource.replace(/^file:\/\//, ""); - if (url.scheme == "file" - && this._sources.has(fileUriAbsPath)) { - return this.sourcesContent[this._sources.indexOf(fileUriAbsPath)] - } - - if ((!url.path || url.path == "/") - && this._sources.has("/" + aSource)) { - return this.sourcesContent[this._sources.indexOf("/" + aSource)]; - } - } - - throw new Error('"' + aSource + '" is not in the SourceMap.'); - }; - - /** - * Returns the generated line and column information for the original source, - * line, and column positions provided. The only argument is an object with - * the following properties: - * - * - source: The filename of the original source. - * - line: The line number in the original source. - * - column: The column number in the original source. - * - * and an object is returned with the following properties: - * - * - line: The line number in the generated source, or null. - * - column: The column number in the generated source, or null. - */ - SourceMapConsumer.prototype.generatedPositionFor = - function SourceMapConsumer_generatedPositionFor(aArgs) { - var needle = { - source: util.getArg(aArgs, 'source'), - originalLine: util.getArg(aArgs, 'line'), - originalColumn: util.getArg(aArgs, 'column') - }; - - if (this.sourceRoot != null) { - needle.source = util.relative(this.sourceRoot, needle.source); - } - - var index = this._findMapping(needle, - this._originalMappings, - "originalLine", - "originalColumn", - util.compareByOriginalPositions); - - if (index >= 0) { - var mapping = this._originalMappings[index]; - - return { - line: util.getArg(mapping, 'generatedLine', null), - column: util.getArg(mapping, 'generatedColumn', null), - lastColumn: util.getArg(mapping, 'lastGeneratedColumn', null) - }; - } - - return { - line: null, - column: null, - lastColumn: null - }; - }; - - /** - * Returns all generated line and column information for the original source - * and line provided. The only argument is an object with the following - * properties: - * - * - source: The filename of the original source. - * - line: The line number in the original source. - * - * and an array of objects is returned, each with the following properties: - * - * - line: The line number in the generated source, or null. - * - column: The column number in the generated source, or null. - */ - SourceMapConsumer.prototype.allGeneratedPositionsFor = - function SourceMapConsumer_allGeneratedPositionsFor(aArgs) { - // When there is no exact match, SourceMapConsumer.prototype._findMapping - // returns the index of the closest mapping less than the needle. By - // setting needle.originalColumn to Infinity, we thus find the last - // mapping for the given line, provided such a mapping exists. - var needle = { - source: util.getArg(aArgs, 'source'), - originalLine: util.getArg(aArgs, 'line'), - originalColumn: Infinity - }; - - if (this.sourceRoot != null) { - needle.source = util.relative(this.sourceRoot, needle.source); - } - - var mappings = []; - - var index = this._findMapping(needle, - this._originalMappings, - "originalLine", - "originalColumn", - util.compareByOriginalPositions); - if (index >= 0) { - var mapping = this._originalMappings[index]; - - while (mapping && mapping.originalLine === needle.originalLine) { - mappings.push({ - line: util.getArg(mapping, 'generatedLine', null), - column: util.getArg(mapping, 'generatedColumn', null), - lastColumn: util.getArg(mapping, 'lastGeneratedColumn', null) - }); - - mapping = this._originalMappings[--index]; - } - } - - return mappings.reverse(); + throw new Error("Subclasses must implement _parseMappings"); }; SourceMapConsumer.GENERATED_ORDER = 1; SourceMapConsumer.ORIGINAL_ORDER = 2; + SourceMapConsumer.GREATEST_LOWER_BOUND = 1; + SourceMapConsumer.LEAST_UPPER_BOUND = 2; + /** * Iterate over each mapping between an original source/line/column and a * generated line/column in this source map. * * @param Function aCallback * The function that is called with each mapping. * @param Object aContext * Optional. If specified, this object will be the value of `this` every @@ -583,18 +174,809 @@ define('source-map/source-map-consumer', generatedColumn: mapping.generatedColumn, originalLine: mapping.originalLine, originalColumn: mapping.originalColumn, name: mapping.name }; }).forEach(aCallback, context); }; + /** + * Returns all generated line and column information for the original source, + * line, and column provided. If no column is provided, returns all mappings + * corresponding to a single line. Otherwise, returns all mappings + * corresponding to a single line and column. + * + * The only argument is an object with the following properties: + * + * - source: The filename of the original source. + * - line: The line number in the original source. + * - column: Optional. the column number in the original source. + * + * and an array of objects is returned, each with the following properties: + * + * - line: The line number in the generated source, or null. + * - column: The column number in the generated source, or null. + */ + SourceMapConsumer.prototype.allGeneratedPositionsFor = + function SourceMapConsumer_allGeneratedPositionsFor(aArgs) { + // When there is no exact match, BasicSourceMapConsumer.prototype._findMapping + // returns the index of the closest mapping less than the needle. By + // setting needle.originalColumn to 0, we thus find the last mapping for + // the given line, provided such a mapping exists. + var needle = { + source: util.getArg(aArgs, 'source'), + originalLine: util.getArg(aArgs, 'line'), + originalColumn: util.getArg(aArgs, 'column', 0) + }; + + if (this.sourceRoot != null) { + needle.source = util.relative(this.sourceRoot, needle.source); + } + + var mappings = []; + + var index = this._findMapping(needle, + this._originalMappings, + "originalLine", + "originalColumn", + util.compareByOriginalPositions, + binarySearch.LEAST_UPPER_BOUND); + if (index >= 0) { + var mapping = this._originalMappings[index]; + var originalLine = mapping.originalLine; + var originalColumn = mapping.originalColumn; + + // Iterate until either we run out of mappings, or we run into + // a mapping for a different line. Since mappings are sorted, this is + // guaranteed to find all mappings for the line we are searching for. + while (mapping && mapping.originalLine === originalLine && + (aArgs.column === undefined || + mapping.originalColumn === originalColumn)) { + mappings.push({ + line: util.getArg(mapping, 'generatedLine', null), + column: util.getArg(mapping, 'generatedColumn', null), + lastColumn: util.getArg(mapping, 'lastGeneratedColumn', null) + }); + + mapping = this._originalMappings[++index]; + } + } + + return mappings; + }; + exports.SourceMapConsumer = SourceMapConsumer; + /** + * A BasicSourceMapConsumer instance represents a parsed source map which we can + * query for information about the original file positions by giving it a file + * position in the generated source. + * + * The only parameter is the raw source map (either as a JSON string, or + * already parsed to an object). According to the spec, source maps have the + * following attributes: + * + * - version: Which version of the source map spec this map is following. + * - sources: An array of URLs to the original source files. + * - names: An array of identifiers which can be referrenced by individual mappings. + * - sourceRoot: Optional. The URL root from which all sources are relative. + * - sourcesContent: Optional. An array of contents of the original source files. + * - mappings: A string of base64 VLQs which contain the actual mappings. + * - file: Optional. The generated file this source map is associated with. + * + * Here is an example source map, taken from the source map spec[0]: + * + * { + * version : 3, + * file: "out.js", + * sourceRoot : "", + * sources: ["foo.js", "bar.js"], + * names: ["src", "maps", "are", "fun"], + * mappings: "AA,AB;;ABCDE;" + * } + * + * [0]: https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit?pli=1# + */ + function BasicSourceMapConsumer(aSourceMap) { + var sourceMap = aSourceMap; + if (typeof aSourceMap === 'string') { + sourceMap = JSON.parse(aSourceMap.replace(/^\)\]\}'/, '')); + } + + var version = util.getArg(sourceMap, 'version'); + var sources = util.getArg(sourceMap, 'sources'); + // Sass 3.3 leaves out the 'names' array, so we deviate from the spec (which + // requires the array) to play nice here. + var names = util.getArg(sourceMap, 'names', []); + var sourceRoot = util.getArg(sourceMap, 'sourceRoot', null); + var sourcesContent = util.getArg(sourceMap, 'sourcesContent', null); + var mappings = util.getArg(sourceMap, 'mappings'); + var file = util.getArg(sourceMap, 'file', null); + + // Once again, Sass deviates from the spec and supplies the version as a + // string rather than a number, so we use loose equality checking here. + if (version != this._version) { + throw new Error('Unsupported version: ' + version); + } + + // Some source maps produce relative source paths like "./foo.js" instead of + // "foo.js". Normalize these first so that future comparisons will succeed. + // See bugzil.la/1090768. + sources = sources.map(util.normalize); + + // Pass `true` below to allow duplicate names and sources. While source maps + // are intended to be compressed and deduplicated, the TypeScript compiler + // sometimes generates source maps with duplicates in them. See Github issue + // #72 and bugzil.la/889492. + this._names = ArraySet.fromArray(names, true); + this._sources = ArraySet.fromArray(sources, true); + + this.sourceRoot = sourceRoot; + this.sourcesContent = sourcesContent; + this._mappings = mappings; + this.file = file; + } + + BasicSourceMapConsumer.prototype = Object.create(SourceMapConsumer.prototype); + BasicSourceMapConsumer.prototype.consumer = SourceMapConsumer; + + /** + * Create a BasicSourceMapConsumer from a SourceMapGenerator. + * + * @param SourceMapGenerator aSourceMap + * The source map that will be consumed. + * @returns BasicSourceMapConsumer + */ + BasicSourceMapConsumer.fromSourceMap = + function SourceMapConsumer_fromSourceMap(aSourceMap) { + var smc = Object.create(BasicSourceMapConsumer.prototype); + + smc._names = ArraySet.fromArray(aSourceMap._names.toArray(), true); + smc._sources = ArraySet.fromArray(aSourceMap._sources.toArray(), true); + smc.sourceRoot = aSourceMap._sourceRoot; + smc.sourcesContent = aSourceMap._generateSourcesContent(smc._sources.toArray(), + smc.sourceRoot); + smc.file = aSourceMap._file; + + smc.__generatedMappings = aSourceMap._mappings.toArray().slice(); + smc.__originalMappings = aSourceMap._mappings.toArray().slice() + .sort(util.compareByOriginalPositions); + + return smc; + }; + + /** + * The version of the source mapping spec that we are consuming. + */ + BasicSourceMapConsumer.prototype._version = 3; + + /** + * The list of original sources. + */ + Object.defineProperty(BasicSourceMapConsumer.prototype, 'sources', { + get: function () { + return this._sources.toArray().map(function (s) { + return this.sourceRoot != null ? util.join(this.sourceRoot, s) : s; + }, this); + } + }); + + /** + * Parse the mappings in a string in to a data structure which we can easily + * query (the ordered arrays in the `this.__generatedMappings` and + * `this.__originalMappings` properties). + */ + BasicSourceMapConsumer.prototype._parseMappings = + function SourceMapConsumer_parseMappings(aStr, aSourceRoot) { + var generatedLine = 1; + var previousGeneratedColumn = 0; + var previousOriginalLine = 0; + var previousOriginalColumn = 0; + var previousSource = 0; + var previousName = 0; + var length = aStr.length; + var index = 0; + var cachedValues = {}; + var temp = {}; + var mapping, str, values, end, value; + + while (index < length) { + if (aStr.charAt(index) === ';') { + generatedLine++; + ++index; + previousGeneratedColumn = 0; + } + else if (aStr.charAt(index) === ',') { + ++index; + } + else { + mapping = {}; + mapping.generatedLine = generatedLine; + + // Because each offset is encoded relative to the previous one, + // many segments often have the same encoding. We can exploit this + // fact by caching the parsed variable length fields of each segment, + // allowing us to avoid a second parse if we encounter the same + // segment again. + for (end = index; end < length; ++end) { + if (this._nextCharIsMappingSeparator(aStr, end)) { + break; + } + } + str = aStr.slice(index, end); + + values = cachedValues[str]; + if (values) { + index += str.length; + } else { + values = []; + while (index < end) { + base64VLQ.decode(aStr, index, temp); + value = temp.value; + index = temp.rest; + values.push(value); + } + cachedValues[str] = values; + } + + // Generated column. + mapping.generatedColumn = previousGeneratedColumn + values[0]; + previousGeneratedColumn = mapping.generatedColumn; + + if (values.length > 1) { + // Original source. + mapping.source = this._sources.at(previousSource + values[1]); + previousSource += values[1]; + if (values.length === 2) { + throw new Error('Found a source, but no line and column'); + } + + // Original line. + mapping.originalLine = previousOriginalLine + values[2]; + previousOriginalLine = mapping.originalLine; + // Lines are stored 0-based + mapping.originalLine += 1; + if (values.length === 3) { + throw new Error('Found a source and line, but no column'); + } + + // Original column. + mapping.originalColumn = previousOriginalColumn + values[3]; + previousOriginalColumn = mapping.originalColumn; + + if (values.length > 4) { + // Original name. + mapping.name = this._names.at(previousName + values[4]); + previousName += values[4]; + } + } + + this.__generatedMappings.push(mapping); + if (typeof mapping.originalLine === 'number') { + this.__originalMappings.push(mapping); + } + } + } + + this.__generatedMappings.sort(util.compareByGeneratedPositions); + this.__originalMappings.sort(util.compareByOriginalPositions); + }; + + /** + * Find the mapping that best matches the hypothetical "needle" mapping that + * we are searching for in the given "haystack" of mappings. + */ + BasicSourceMapConsumer.prototype._findMapping = + function SourceMapConsumer_findMapping(aNeedle, aMappings, aLineName, + aColumnName, aComparator, aBias) { + // To return the position we are searching for, we must first find the + // mapping for the given position and then return the opposite position it + // points to. Because the mappings are sorted, we can use binary search to + // find the best mapping. + + if (aNeedle[aLineName] <= 0) { + throw new TypeError('Line must be greater than or equal to 1, got ' + + aNeedle[aLineName]); + } + if (aNeedle[aColumnName] < 0) { + throw new TypeError('Column must be greater than or equal to 0, got ' + + aNeedle[aColumnName]); + } + + return binarySearch.search(aNeedle, aMappings, aComparator, aBias); + }; + + /** + * Compute the last column for each generated mapping. The last column is + * inclusive. + */ + BasicSourceMapConsumer.prototype.computeColumnSpans = + function SourceMapConsumer_computeColumnSpans() { + for (var index = 0; index < this._generatedMappings.length; ++index) { + var mapping = this._generatedMappings[index]; + + // Mappings do not contain a field for the last generated columnt. We + // can come up with an optimistic estimate, however, by assuming that + // mappings are contiguous (i.e. given two consecutive mappings, the + // first mapping ends where the second one starts). + if (index + 1 < this._generatedMappings.length) { + var nextMapping = this._generatedMappings[index + 1]; + + if (mapping.generatedLine === nextMapping.generatedLine) { + mapping.lastGeneratedColumn = nextMapping.generatedColumn - 1; + continue; + } + } + + // The last mapping for each line spans the entire line. + mapping.lastGeneratedColumn = Infinity; + } + }; + + /** + * Returns the original source, line, and column information for the generated + * source's line and column positions provided. The only argument is an object + * with the following properties: + * + * - line: The line number in the generated source. + * - column: The column number in the generated source. + * - bias: Either 'SourceMapConsumer.GREATEST_LOWER_BOUND' or + * 'SourceMapConsumer.LEAST_UPPER_BOUND'. Specifies whether to return the + * closest element that is smaller than or greater than the one we are + * searching for, respectively, if the exact element cannot be found. + * Defaults to 'SourceMapConsumer.GREATEST_LOWER_BOUND'. + * + * and an object is returned with the following properties: + * + * - source: The original source file, or null. + * - line: The line number in the original source, or null. + * - column: The column number in the original source, or null. + * - name: The original identifier, or null. + */ + BasicSourceMapConsumer.prototype.originalPositionFor = + function SourceMapConsumer_originalPositionFor(aArgs) { + var needle = { + generatedLine: util.getArg(aArgs, 'line'), + generatedColumn: util.getArg(aArgs, 'column') + }; + + var index = this._findMapping( + needle, + this._generatedMappings, + "generatedLine", + "generatedColumn", + util.compareByGeneratedPositions, + util.getArg(aArgs, 'bias', SourceMapConsumer.GREATEST_LOWER_BOUND) + ); + + if (index >= 0) { + var mapping = this._generatedMappings[index]; + + if (mapping.generatedLine === needle.generatedLine) { + var source = util.getArg(mapping, 'source', null); + if (source != null && this.sourceRoot != null) { + source = util.join(this.sourceRoot, source); + } + return { + source: source, + line: util.getArg(mapping, 'originalLine', null), + column: util.getArg(mapping, 'originalColumn', null), + name: util.getArg(mapping, 'name', null) + }; + } + } + + return { + source: null, + line: null, + column: null, + name: null + }; + }; + + /** + * Returns the original source content. The only argument is the url of the + * original source file. Returns null if no original source content is + * availible. + */ + BasicSourceMapConsumer.prototype.sourceContentFor = + function SourceMapConsumer_sourceContentFor(aSource, nullOnMissing) { + if (!this.sourcesContent) { + return null; + } + + if (this.sourceRoot != null) { + aSource = util.relative(this.sourceRoot, aSource); + } + + if (this._sources.has(aSource)) { + return this.sourcesContent[this._sources.indexOf(aSource)]; + } + + var url; + if (this.sourceRoot != null + && (url = util.urlParse(this.sourceRoot))) { + // XXX: file:// URIs and absolute paths lead to unexpected behavior for + // many users. We can help them out when they expect file:// URIs to + // behave like it would if they were running a local HTTP server. See + // https://bugzilla.mozilla.org/show_bug.cgi?id=885597. + var fileUriAbsPath = aSource.replace(/^file:\/\//, ""); + if (url.scheme == "file" + && this._sources.has(fileUriAbsPath)) { + return this.sourcesContent[this._sources.indexOf(fileUriAbsPath)] + } + + if ((!url.path || url.path == "/") + && this._sources.has("/" + aSource)) { + return this.sourcesContent[this._sources.indexOf("/" + aSource)]; + } + } + + // This function is used recursively from + // IndexedSourceMapConsumer.prototype.sourceContentFor. In that case, we + // don't want to throw if we can't find the source - we just want to + // return null, so we provide a flag to exit gracefully. + if (nullOnMissing) { + return null; + } + else { + throw new Error('"' + aSource + '" is not in the SourceMap.'); + } + }; + + /** + * Returns the generated line and column information for the original source, + * line, and column positions provided. The only argument is an object with + * the following properties: + * + * - source: The filename of the original source. + * - line: The line number in the original source. + * - column: The column number in the original source. + * - bias: Either 'SourceMapConsumer.GREATEST_LOWER_BOUND' or + * 'SourceMapConsumer.LEAST_UPPER_BOUND'. Specifies whether to return the + * closest element that is smaller than or greater than the one we are + * searching for, respectively, if the exact element cannot be found. + * Defaults to 'SourceMapConsumer.GREATEST_LOWER_BOUND'. + * + * and an object is returned with the following properties: + * + * - line: The line number in the generated source, or null. + * - column: The column number in the generated source, or null. + */ + BasicSourceMapConsumer.prototype.generatedPositionFor = + function SourceMapConsumer_generatedPositionFor(aArgs) { + var needle = { + source: util.getArg(aArgs, 'source'), + originalLine: util.getArg(aArgs, 'line'), + originalColumn: util.getArg(aArgs, 'column') + }; + + if (this.sourceRoot != null) { + needle.source = util.relative(this.sourceRoot, needle.source); + } + + var index = this._findMapping( + needle, + this._originalMappings, + "originalLine", + "originalColumn", + util.compareByOriginalPositions, + util.getArg(aArgs, 'bias', SourceMapConsumer.GREATEST_LOWER_BOUND) + ); + + if (index >= 0) { + var mapping = this._originalMappings[index]; + + if (mapping.source === needle.source) { + return { + line: util.getArg(mapping, 'generatedLine', null), + column: util.getArg(mapping, 'generatedColumn', null), + lastColumn: util.getArg(mapping, 'lastGeneratedColumn', null) + }; + } + } + + return { + line: null, + column: null, + lastColumn: null + }; + }; + + exports.BasicSourceMapConsumer = BasicSourceMapConsumer; + + /** + * An IndexedSourceMapConsumer instance represents a parsed source map which + * we can query for information. It differs from BasicSourceMapConsumer in + * that it takes "indexed" source maps (i.e. ones with a "sections" field) as + * input. + * + * The only parameter is a raw source map (either as a JSON string, or already + * parsed to an object). According to the spec for indexed source maps, they + * have the following attributes: + * + * - version: Which version of the source map spec this map is following. + * - file: Optional. The generated file this source map is associated with. + * - sections: A list of section definitions. + * + * Each value under the "sections" field has two fields: + * - offset: The offset into the original specified at which this section + * begins to apply, defined as an object with a "line" and "column" + * field. + * - map: A source map definition. This source map could also be indexed, + * but doesn't have to be. + * + * Instead of the "map" field, it's also possible to have a "url" field + * specifying a URL to retrieve a source map from, but that's currently + * unsupported. + * + * Here's an example source map, taken from the source map spec[0], but + * modified to omit a section which uses the "url" field. + * + * { + * version : 3, + * file: "app.js", + * sections: [{ + * offset: {line:100, column:10}, + * map: { + * version : 3, + * file: "section.js", + * sources: ["foo.js", "bar.js"], + * names: ["src", "maps", "are", "fun"], + * mappings: "AAAA,E;;ABCDE;" + * } + * }], + * } + * + * [0]: https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit#heading=h.535es3xeprgt + */ + function IndexedSourceMapConsumer(aSourceMap) { + var sourceMap = aSourceMap; + if (typeof aSourceMap === 'string') { + sourceMap = JSON.parse(aSourceMap.replace(/^\)\]\}'/, '')); + } + + var version = util.getArg(sourceMap, 'version'); + var sections = util.getArg(sourceMap, 'sections'); + + if (version != this._version) { + throw new Error('Unsupported version: ' + version); + } + + var lastOffset = { + line: -1, + column: 0 + }; + this._sections = sections.map(function (s) { + if (s.url) { + // The url field will require support for asynchronicity. + // See https://github.com/mozilla/source-map/issues/16 + throw new Error('Support for url field in sections not implemented.'); + } + var offset = util.getArg(s, 'offset'); + var offsetLine = util.getArg(offset, 'line'); + var offsetColumn = util.getArg(offset, 'column'); + + if (offsetLine < lastOffset.line || + (offsetLine === lastOffset.line && offsetColumn < lastOffset.column)) { + throw new Error('Section offsets must be ordered and non-overlapping.'); + } + lastOffset = offset; + + return { + generatedOffset: { + // The offset fields are 0-based, but we use 1-based indices when + // encoding/decoding from VLQ. + generatedLine: offsetLine + 1, + generatedColumn: offsetColumn + 1 + }, + consumer: new SourceMapConsumer(util.getArg(s, 'map')) + } + }); + } + + IndexedSourceMapConsumer.prototype = Object.create(SourceMapConsumer.prototype); + IndexedSourceMapConsumer.prototype.constructor = SourceMapConsumer; + + /** + * The version of the source mapping spec that we are consuming. + */ + IndexedSourceMapConsumer.prototype._version = 3; + + /** + * The list of original sources. + */ + Object.defineProperty(IndexedSourceMapConsumer.prototype, 'sources', { + get: function () { + var sources = []; + for (var i = 0; i < this._sections.length; i++) { + for (var j = 0; j < this._sections[i].consumer.sources.length; j++) { + sources.push(this._sections[i].consumer.sources[j]); + } + }; + return sources; + } + }); + + /** + * Returns the original source, line, and column information for the generated + * source's line and column positions provided. The only argument is an object + * with the following properties: + * + * - line: The line number in the generated source. + * - column: The column number in the generated source. + * + * and an object is returned with the following properties: + * + * - source: The original source file, or null. + * - line: The line number in the original source, or null. + * - column: The column number in the original source, or null. + * - name: The original identifier, or null. + */ + IndexedSourceMapConsumer.prototype.originalPositionFor = + function IndexedSourceMapConsumer_originalPositionFor(aArgs) { + var needle = { + generatedLine: util.getArg(aArgs, 'line'), + generatedColumn: util.getArg(aArgs, 'column') + }; + + // Find the section containing the generated position we're trying to map + // to an original position. + var sectionIndex = binarySearch.search(needle, this._sections, + function(needle, section) { + var cmp = needle.generatedLine - section.generatedOffset.generatedLine; + if (cmp) { + return cmp; + } + + return (needle.generatedColumn - + section.generatedOffset.generatedColumn); + }); + var section = this._sections[sectionIndex]; + + if (!section) { + return { + source: null, + line: null, + column: null, + name: null + }; + } + + return section.consumer.originalPositionFor({ + line: needle.generatedLine - + (section.generatedOffset.generatedLine - 1), + column: needle.generatedColumn - + (section.generatedOffset.generatedLine === needle.generatedLine + ? section.generatedOffset.generatedColumn - 1 + : 0), + bias: aArgs.bias + }); + }; + + /** + * Returns the original source content. The only argument is the url of the + * original source file. Returns null if no original source content is + * available. + */ + IndexedSourceMapConsumer.prototype.sourceContentFor = + function IndexedSourceMapConsumer_sourceContentFor(aSource, nullOnMissing) { + for (var i = 0; i < this._sections.length; i++) { + var section = this._sections[i]; + + var content = section.consumer.sourceContentFor(aSource, true); + if (content) { + return content; + } + } + if (nullOnMissing) { + return null; + } + else { + throw new Error('"' + aSource + '" is not in the SourceMap.'); + } + }; + + /** + * Returns the generated line and column information for the original source, + * line, and column positions provided. The only argument is an object with + * the following properties: + * + * - source: The filename of the original source. + * - line: The line number in the original source. + * - column: The column number in the original source. + * + * and an object is returned with the following properties: + * + * - line: The line number in the generated source, or null. + * - column: The column number in the generated source, or null. + */ + IndexedSourceMapConsumer.prototype.generatedPositionFor = + function IndexedSourceMapConsumer_generatedPositionFor(aArgs) { + for (var i = 0; i < this._sections.length; i++) { + var section = this._sections[i]; + + // Only consider this section if the requested source is in the list of + // sources of the consumer. + if (section.consumer.sources.indexOf(util.getArg(aArgs, 'source')) === -1) { + continue; + } + var generatedPosition = section.consumer.generatedPositionFor(aArgs); + if (generatedPosition) { + var ret = { + line: generatedPosition.line + + (section.generatedOffset.generatedLine - 1), + column: generatedPosition.column + + (section.generatedOffset.generatedLine === generatedPosition.line + ? section.generatedOffset.generatedColumn - 1 + : 0) + }; + return ret; + } + } + + return { + line: null, + column: null + }; + }; + + /** + * Parse the mappings in a string in to a data structure which we can easily + * query (the ordered arrays in the `this.__generatedMappings` and + * `this.__originalMappings` properties). + */ + IndexedSourceMapConsumer.prototype._parseMappings = + function IndexedSourceMapConsumer_parseMappings(aStr, aSourceRoot) { + this.__generatedMappings = []; + this.__originalMappings = []; + for (var i = 0; i < this._sections.length; i++) { + var section = this._sections[i]; + var sectionMappings = section.consumer._generatedMappings; + for (var j = 0; j < sectionMappings.length; j++) { + var mapping = sectionMappings[i]; + + var source = mapping.source; + var sourceRoot = section.consumer.sourceRoot; + + if (source != null && sourceRoot != null) { + source = util.join(sourceRoot, source); + } + + // The mappings coming from the consumer for the section have + // generated positions relative to the start of the section, so we + // need to offset them to be relative to the start of the concatenated + // generated file. + var adjustedMapping = { + source: source, + generatedLine: mapping.generatedLine + + (section.generatedOffset.generatedLine - 1), + generatedColumn: mapping.column + + (section.generatedOffset.generatedLine === mapping.generatedLine) + ? section.generatedOffset.generatedColumn - 1 + : 0, + originalLine: mapping.originalLine, + originalColumn: mapping.originalColumn, + name: mapping.name + }; + + this.__generatedMappings.push(adjustedMapping); + if (typeof adjustedMapping.originalLine === 'number') { + this.__originalMappings.push(adjustedMapping); + } + }; + }; + + this.__generatedMappings.sort(util.compareByGeneratedPositions); + this.__originalMappings.sort(util.compareByOriginalPositions); + }; + + exports.IndexedSourceMapConsumer = IndexedSourceMapConsumer; + }); /* -*- Mode: js; js-indent-level: 2; -*- */ /* * Copyright 2011 Mozilla Foundation and contributors * Licensed under the New BSD license. See LICENSE or: * http://opensource.org/licenses/BSD-3-Clause */ define('source-map/util', ['require', 'exports', 'module' , ], function(require, exports, module) { @@ -846,27 +1228,27 @@ define('source-map/util', ['require', 'e return cmp; } cmp = mappingA.originalColumn - mappingB.originalColumn; if (cmp || onlyCompareOriginal) { return cmp; } - cmp = strcmp(mappingA.name, mappingB.name); + cmp = mappingA.generatedColumn - mappingB.generatedColumn; if (cmp) { return cmp; } cmp = mappingA.generatedLine - mappingB.generatedLine; if (cmp) { return cmp; } - return mappingA.generatedColumn - mappingB.generatedColumn; + return strcmp(mappingA.name, mappingB.name); }; exports.compareByOriginalPositions = compareByOriginalPositions; /** * Comparator between two mappings where the generated positions are * compared. * * Optionally pass in `true` as `onlyCompareGenerated` to consider two @@ -910,82 +1292,119 @@ define('source-map/util', ['require', 'e /* -*- Mode: js; js-indent-level: 2; -*- */ /* * Copyright 2011 Mozilla Foundation and contributors * Licensed under the New BSD license. See LICENSE or: * http://opensource.org/licenses/BSD-3-Clause */ define('source-map/binary-search', ['require', 'exports', 'module' , ], function(require, exports, module) { + exports.GREATEST_LOWER_BOUND = 1; + exports.LEAST_UPPER_BOUND = 2; + /** * Recursive implementation of binary search. * * @param aLow Indices here and lower do not contain the needle. * @param aHigh Indices here and higher do not contain the needle. * @param aNeedle The element being searched for. * @param aHaystack The non-empty array being searched. * @param aCompare Function which takes two elements and returns -1, 0, or 1. + * @param aBias Either 'binarySearch.GREATEST_LOWER_BOUND' or + * 'binarySearch.LEAST_UPPER_BOUND'. Specifies whether to return the + * closest element that is smaller than or greater than the one we are + * searching for, respectively, if the exact element cannot be found. */ - function recursiveSearch(aLow, aHigh, aNeedle, aHaystack, aCompare) { + function recursiveSearch(aLow, aHigh, aNeedle, aHaystack, aCompare, aBias) { // This function terminates when one of the following is true: // // 1. We find the exact element we are looking for. // // 2. We did not find the exact element, but we can return the index of - // the next closest element that is less than that element. + // the next-closest element. // // 3. We did not find the exact element, and there is no next-closest - // element which is less than the one we are searching for, so we - // return -1. + // element than the one we are searching for, so we return -1. var mid = Math.floor((aHigh - aLow) / 2) + aLow; var cmp = aCompare(aNeedle, aHaystack[mid], true); if (cmp === 0) { // Found the element we are looking for. return mid; } else if (cmp > 0) { - // aHaystack[mid] is greater than our needle. + // Our needle is greater than aHaystack[mid]. if (aHigh - mid > 1) { // The element is in the upper half. - return recursiveSearch(mid, aHigh, aNeedle, aHaystack, aCompare); + return recursiveSearch(mid, aHigh, aNeedle, aHaystack, aCompare, aBias); } - // We did not find an exact match, return the next closest one - // (termination case 2). - return mid; + + // The exact needle element was not found in this haystack. Determine if + // we are in termination case (3) or (2) and return the appropriate thing. + if (aBias == exports.LEAST_UPPER_BOUND) { + return aHigh < aHaystack.length ? aHigh : -1; + } else { + return mid; + } } else { - // aHaystack[mid] is less than our needle. + // Our needle is less than aHaystack[mid]. if (mid - aLow > 1) { // The element is in the lower half. - return recursiveSearch(aLow, mid, aNeedle, aHaystack, aCompare); + return recursiveSearch(aLow, mid, aNeedle, aHaystack, aCompare, aBias); } - // The exact needle element was not found in this haystack. Determine if - // we are in termination case (2) or (3) and return the appropriate thing. - return aLow < 0 ? -1 : aLow; + + // we are in termination case (3) or (2) and return the appropriate thing. + if (aBias == exports.LEAST_UPPER_BOUND) { + return mid; + } else { + return aLow < 0 ? -1 : aLow; + } } } /** * This is an implementation of binary search which will always try and return - * the index of next lowest value checked if there is no exact hit. This is - * because mappings between original and generated line/col pairs are single - * points, and there is an implicit region between each of them, so a miss - * just means that you aren't on the very start of a region. + * the index of the closest element if there is no exact hit. This is because + * mappings between original and generated line/col pairs are single points, + * and there is an implicit region between each of them, so a miss just means + * that you aren't on the very start of a region. * * @param aNeedle The element you are looking for. * @param aHaystack The array that is being searched. * @param aCompare A function which takes the needle and an element in the * array and returns -1, 0, or 1 depending on whether the needle is less * than, equal to, or greater than the element, respectively. + * @param aBias Either 'binarySearch.GREATEST_LOWER_BOUND' or + * 'binarySearch.LEAST_UPPER_BOUND'. Specifies whether to return the + * closest element that is smaller than or greater than the one we are + * searching for, respectively, if the exact element cannot be found. + * Defaults to 'binarySearch.GREATEST_LOWER_BOUND'. */ - exports.search = function search(aNeedle, aHaystack, aCompare) { + exports.search = function search(aNeedle, aHaystack, aCompare, aBias) { if (aHaystack.length === 0) { return -1; } - return recursiveSearch(-1, aHaystack.length, aNeedle, aHaystack, aCompare) + + var index = recursiveSearch(-1, aHaystack.length, aNeedle, aHaystack, + aCompare, aBias || exports.GREATEST_LOWER_BOUND); + if (index < 0) { + return -1; + } + + // We have found either the exact element, or the next-closest element than + // the one we are searching for. However, there may be more than one such + // element. Make sure we always return the smallest of these. + while (index - 1 >= 0) { + if (aCompare(aHaystack[index], aHaystack[index - 1], true) !== 0) { + break; + } + --index; + } + + return index; }; }); /* -*- Mode: js; js-indent-level: 2; -*- */ /* * Copyright 2011 Mozilla Foundation and contributors * Licensed under the New BSD license. See LICENSE or: * http://opensource.org/licenses/BSD-3-Clause @@ -1138,29 +1557,29 @@ define('source-map/base64-vlq', ['requir // binary: 011111 var VLQ_BASE_MASK = VLQ_BASE - 1; // binary: 100000 var VLQ_CONTINUATION_BIT = VLQ_BASE; /** * Converts from a two-complement value to a value where the sign bit is - * is placed in the least significant bit. For example, as decimals: + * placed in the least significant bit. For example, as decimals: * 1 becomes 2 (10 binary), -1 becomes 3 (11 binary) * 2 becomes 4 (100 binary), -2 becomes 5 (101 binary) */ function toVLQSigned(aValue) { return aValue < 0 ? ((-aValue) << 1) + 1 : (aValue << 1) + 0; } /** * Converts to a two-complement value from a value where the sign bit is - * is placed in the least significant bit. For example, as decimals: + * placed in the least significant bit. For example, as decimals: * 2 (10 binary) becomes 1, 3 (11 binary) becomes -1 * 4 (100 binary) becomes 2, 5 (101 binary) becomes -2 */ function fromVLQSigned(aValue) { var isNegative = (aValue & 1) === 1; var shifted = aValue >> 1; return isNegative ? -shifted @@ -1189,36 +1608,35 @@ define('source-map/base64-vlq', ['requir return encoded; }; /** * Decodes the next base 64 VLQ value from the given string and returns the * value and the rest of the string via the out parameter. */ - exports.decode = function base64VLQ_decode(aStr, aOutParam) { - var i = 0; + exports.decode = function base64VLQ_decode(aStr, aIndex, aOutParam) { var strLen = aStr.length; var result = 0; var shift = 0; var continuation, digit; do { - if (i >= strLen) { + if (aIndex >= strLen) { throw new Error("Expected more digits in base 64 VLQ value."); } - digit = base64.decode(aStr.charAt(i++)); + digit = base64.decode(aStr.charAt(aIndex++)); continuation = !!(digit & VLQ_CONTINUATION_BIT); digit &= VLQ_BASE_MASK; result = result + (digit << shift); shift += VLQ_BASE_SHIFT; } while (continuation); aOutParam.value = fromVLQSigned(result); - aOutParam.rest = aStr.slice(i); + aOutParam.rest = aIndex; }; }); /* -*- Mode: js; js-indent-level: 2; -*- */ /* * Copyright 2011 Mozilla Foundation and contributors * Licensed under the New BSD license. See LICENSE or: * http://opensource.org/licenses/BSD-3-Clause @@ -1257,39 +1675,41 @@ define('source-map/base64', ['require', }); /* -*- Mode: js; js-indent-level: 2; -*- */ /* * Copyright 2011 Mozilla Foundation and contributors * Licensed under the New BSD license. See LICENSE or: * http://opensource.org/licenses/BSD-3-Clause */ -define('source-map/source-map-generator', ['require', 'exports', 'module' , 'source-map/base64-vlq', 'source-map/util', 'source-map/array-set'], function(require, exports, module) { +define('source-map/source-map-generator', ['require', 'exports', 'module' , 'source-map/base64-vlq', 'source-map/util', 'source-map/array-set', 'source-map/mapping-list'], function(require, exports, module) { var base64VLQ = require('source-map/base64-vlq'); var util = require('source-map/util'); var ArraySet = require('source-map/array-set').ArraySet; + var MappingList = require('source-map/mapping-list').MappingList; /** * An instance of the SourceMapGenerator represents a source map which is * being built incrementally. You may pass an object with the following * properties: * * - file: The filename of the generated source. * - sourceRoot: A root for all relative URLs in this source map. */ function SourceMapGenerator(aArgs) { if (!aArgs) { aArgs = {}; } this._file = util.getArg(aArgs, 'file', null); this._sourceRoot = util.getArg(aArgs, 'sourceRoot', null); + this._skipValidation = util.getArg(aArgs, 'skipValidation', false); this._sources = new ArraySet(); this._names = new ArraySet(); - this._mappings = []; + this._mappings = new MappingList(); this._sourcesContents = null; } SourceMapGenerator.prototype._version = 3; /** * Creates a new SourceMapGenerator based on a SourceMapConsumer * @@ -1349,27 +1769,29 @@ define('source-map/source-map-generator' */ SourceMapGenerator.prototype.addMapping = function SourceMapGenerator_addMapping(aArgs) { var generated = util.getArg(aArgs, 'generated'); var original = util.getArg(aArgs, 'original', null); var source = util.getArg(aArgs, 'source', null); var name = util.getArg(aArgs, 'name', null); - this._validateMapping(generated, original, source, name); + if (!this._skipValidation) { + this._validateMapping(generated, original, source, name); + } if (source != null && !this._sources.has(source)) { this._sources.add(source); } if (name != null && !this._names.has(name)) { this._names.add(name); } - this._mappings.push({ + this._mappings.add({ generatedLine: generated.line, generatedColumn: generated.column, originalLine: original != null && original.line, originalColumn: original != null && original.column, source: source, name: name }); }; @@ -1436,17 +1858,17 @@ define('source-map/source-map-generator' sourceFile = util.relative(sourceRoot, sourceFile); } // Applying the SourceMap can add and remove items from the sources and // the names array. var newSources = new ArraySet(); var newNames = new ArraySet(); // Find mappings for the "sourceFile" - this._mappings.forEach(function (mapping) { + this._mappings.unsortedForEach(function (mapping) { if (mapping.source === sourceFile && mapping.originalLine != null) { // Check if it can be mapped by the source map, then update the mapping. var original = aSourceMapConsumer.originalPositionFor({ line: mapping.originalLine, column: mapping.originalColumn }); if (original.source != null) { // Copy mapping @@ -1542,36 +1964,31 @@ define('source-map/source-map-generator' var previousGeneratedLine = 1; var previousOriginalColumn = 0; var previousOriginalLine = 0; var previousName = 0; var previousSource = 0; var result = ''; var mapping; - // The mappings must be guaranteed to be in sorted order before we start - // serializing them or else the generated line numbers (which are defined - // via the ';' separators) will be all messed up. Note: it might be more - // performant to maintain the sorting as we insert them, rather than as we - // serialize them, but the big O is the same either way. - this._mappings.sort(util.compareByGeneratedPositions); + var mappings = this._mappings.toArray(); - for (var i = 0, len = this._mappings.length; i < len; i++) { - mapping = this._mappings[i]; + for (var i = 0, len = mappings.length; i < len; i++) { + mapping = mappings[i]; if (mapping.generatedLine !== previousGeneratedLine) { previousGeneratedColumn = 0; while (mapping.generatedLine !== previousGeneratedLine) { result += ';'; previousGeneratedLine++; } } else { if (i > 0) { - if (!util.compareByGeneratedPositions(mapping, this._mappings[i - 1])) { + if (!util.compareByGeneratedPositions(mapping, mappings[i - 1])) { continue; } result += ','; } } result += base64VLQ.encode(mapping.generatedColumn - previousGeneratedColumn); @@ -1643,39 +2060,127 @@ define('source-map/source-map-generator' return map; }; /** * Render the source map being generated to a string. */ SourceMapGenerator.prototype.toString = function SourceMapGenerator_toString() { - return JSON.stringify(this); + return JSON.stringify(this.toJSON()); }; exports.SourceMapGenerator = SourceMapGenerator; }); /* -*- Mode: js; js-indent-level: 2; -*- */ /* + * Copyright 2014 Mozilla Foundation and contributors + * Licensed under the New BSD license. See LICENSE or: + * http://opensource.org/licenses/BSD-3-Clause + */ +define('source-map/mapping-list', ['require', 'exports', 'module' , 'source-map/util'], function(require, exports, module) { + + var util = require('source-map/util'); + + /** + * Determine whether mappingB is after mappingA with respect to generated + * position. + */ + function generatedPositionAfter(mappingA, mappingB) { + // Optimized for most common case + var lineA = mappingA.generatedLine; + var lineB = mappingB.generatedLine; + var columnA = mappingA.generatedColumn; + var columnB = mappingB.generatedColumn; + return lineB > lineA || lineB == lineA && columnB >= columnA || + util.compareByGeneratedPositions(mappingA, mappingB) <= 0; + } + + /** + * A data structure to provide a sorted view of accumulated mappings in a + * performance conscious manner. It trades a neglibable overhead in general + * case for a large speedup in case of mappings being added in order. + */ + function MappingList() { + this._array = []; + this._sorted = true; + // Serves as infimum + this._last = {generatedLine: -1, generatedColumn: 0}; + } + + /** + * Iterate through internal items. This method takes the same arguments that + * `Array.prototype.forEach` takes. + * + * NOTE: The order of the mappings is NOT guaranteed. + */ + MappingList.prototype.unsortedForEach = + function MappingList_forEach(aCallback, aThisArg) { + this._array.forEach(aCallback, aThisArg); + }; + + /** + * Add the given source mapping. + * + * @param Object aMapping + */ + MappingList.prototype.add = function MappingList_add(aMapping) { + var mapping; + if (generatedPositionAfter(this._last, aMapping)) { + this._last = aMapping; + this._array.push(aMapping); + } else { + this._sorted = false; + this._array.push(aMapping); + } + }; + + /** + * Returns the flat, sorted array of mappings. The mappings are sorted by + * generated position. + * + * WARNING: This method returns internal data without copying, for + * performance. The return value must NOT be mutated, and should be treated as + * an immutable borrow. If you want to take ownership, you must make your own + * copy. + */ + MappingList.prototype.toArray = function MappingList_toArray() { + if (!this._sorted) { + this._array.sort(util.compareByGeneratedPositions); + this._sorted = true; + } + return this._array; + }; + + exports.MappingList = MappingList; + +}); +/* -*- Mode: js; js-indent-level: 2; -*- */ +/* * Copyright 2011 Mozilla Foundation and contributors * Licensed under the New BSD license. See LICENSE or: * http://opensource.org/licenses/BSD-3-Clause */ define('source-map/source-node', ['require', 'exports', 'module' , 'source-map/source-map-generator', 'source-map/util'], function(require, exports, module) { var SourceMapGenerator = require('source-map/source-map-generator').SourceMapGenerator; var util = require('source-map/util'); // Matches a Windows-style `\r\n` newline or a `\n` newline used by all other // operating systems these days (capturing the result). var REGEX_NEWLINE = /(\r?\n)/; - // Matches a Windows-style newline, or any character. - var REGEX_CHARACTER = /\r\n|[\s\S]/g; + // Newline character code for charCodeAt() comparisons + var NEWLINE_CODE = 10; + + // Private symbol for identifying `SourceNode`s when multiple versions of + // the source-map library are loaded. This MUST NOT CHANGE across + // versions! + var isSourceNode = "$$$isSourceNode$$$"; /** * SourceNodes provide a way to abstract over interpolating/concatenating * snippets of generated JavaScript source code while maintaining the line and * column information associated with the original source code. * * @param aLine The original line number. * @param aColumn The original column number. @@ -1686,16 +2191,17 @@ define('source-map/source-node', ['requi */ function SourceNode(aLine, aColumn, aSource, aChunks, aName) { this.children = []; this.sourceContents = {}; this.line = aLine == null ? null : aLine; this.column = aColumn == null ? null : aColumn; this.source = aSource == null ? null : aSource; this.name = aName == null ? null : aName; + this[isSourceNode] = true; if (aChunks != null) this.add(aChunks); } /** * Creates a SourceNode from generated code and a SourceMapConsumer. * * @param aGeneratedCode The generated code * @param aSourceMapConsumer The SourceMap for the generated code @@ -1816,17 +2322,17 @@ define('source-map/source-node', ['requi * SourceNode, or an array where each member is one of those things. */ SourceNode.prototype.add = function SourceNode_add(aChunk) { if (Array.isArray(aChunk)) { aChunk.forEach(function (chunk) { this.add(chunk); }, this); } - else if (aChunk instanceof SourceNode || typeof aChunk === "string") { + else if (aChunk[isSourceNode] || typeof aChunk === "string") { if (aChunk) { this.children.push(aChunk); } } else { throw new TypeError( "Expected a SourceNode, string, or an array of SourceNodes and strings. Got " + aChunk ); @@ -1841,17 +2347,17 @@ define('source-map/source-node', ['requi * SourceNode, or an array where each member is one of those things. */ SourceNode.prototype.prepend = function SourceNode_prepend(aChunk) { if (Array.isArray(aChunk)) { for (var i = aChunk.length-1; i >= 0; i--) { this.prepend(aChunk[i]); } } - else if (aChunk instanceof SourceNode || typeof aChunk === "string") { + else if (aChunk[isSourceNode] || typeof aChunk === "string") { this.children.unshift(aChunk); } else { throw new TypeError( "Expected a SourceNode, string, or an array of SourceNodes and strings. Got " + aChunk ); } return this; @@ -1863,17 +2369,17 @@ define('source-map/source-node', ['requi * snippet and the its original associated source's line/column location. * * @param aFn The traversal function. */ SourceNode.prototype.walk = function SourceNode_walk(aFn) { var chunk; for (var i = 0, len = this.children.length; i < len; i++) { chunk = this.children[i]; - if (chunk instanceof SourceNode) { + if (chunk[isSourceNode]) { chunk.walk(aFn); } else { if (chunk !== '') { aFn(chunk, { source: this.source, line: this.line, column: this.column, name: this.name }); @@ -1908,17 +2414,17 @@ define('source-map/source-node', ['requi * Call String.prototype.replace on the very right-most source snippet. Useful * for trimming whitespace from the end of a source node, etc. * * @param aPattern The pattern to replace. * @param aReplacement The thing to replace the pattern with. */ SourceNode.prototype.replaceRight = function SourceNode_replaceRight(aPattern, aReplacement) { var lastChild = this.children[this.children.length - 1]; - if (lastChild instanceof SourceNode) { + if (lastChild[isSourceNode]) { lastChild.replaceRight(aPattern, aReplacement); } else if (typeof lastChild === 'string') { this.children[this.children.length - 1] = lastChild.replace(aPattern, aReplacement); } else { this.children.push(''.replace(aPattern, aReplacement)); } @@ -1941,17 +2447,17 @@ define('source-map/source-node', ['requi * Walk over the tree of SourceNodes. The walking function is called for each * source file content and is passed the filename and source content. * * @param aFn The traversal function. */ SourceNode.prototype.walkSourceContents = function SourceNode_walkSourceContents(aFn) { for (var i = 0, len = this.children.length; i < len; i++) { - if (this.children[i] instanceof SourceNode) { + if (this.children[i][isSourceNode]) { this.children[i].walkSourceContents(aFn); } } var sources = Object.keys(this.sourceContents); for (var i = 0, len = sources.length; i < len; i++) { aFn(util.fromSetString(sources[i]), this.sourceContents[sources[i]]); } @@ -2017,22 +2523,22 @@ define('source-map/source-node', ['requi generated: { line: generated.line, column: generated.column } }); lastOriginalSource = null; sourceMappingActive = false; } - chunk.match(REGEX_CHARACTER).forEach(function (ch, idx, array) { - if (REGEX_NEWLINE.test(ch)) { + for (var idx = 0, length = chunk.length; idx < length; idx++) { + if (chunk.charCodeAt(idx) === NEWLINE_CODE) { generated.line++; generated.column = 0; // Mappings end at eol - if (idx + 1 === array.length) { + if (idx + 1 === length) { lastOriginalSource = null; sourceMappingActive = false; } else if (sourceMappingActive) { map.addMapping({ source: original.source, original: { line: original.line, column: original.column @@ -2040,19 +2546,19 @@ define('source-map/source-node', ['requi generated: { line: generated.line, column: generated.column }, name: original.name }); } } else { - generated.column += ch.length; + generated.column++; } - }); + } }); this.walkSourceContents(function (sourceFile, sourceContent) { map.setSourceContent(sourceFile, sourceContent); }); return { code: generated.code, map: map }; };
--- a/toolkit/devtools/sourcemap/source-map.js +++ b/toolkit/devtools/sourcemap/source-map.js @@ -151,39 +151,41 @@ define.Domain = Domain; define.globalDomain = new Domain(); var require = define.globalDomain.require.bind(define.globalDomain); /* -*- Mode: js; js-indent-level: 2; -*- */ /* * Copyright 2011 Mozilla Foundation and contributors * Licensed under the New BSD license. See LICENSE or: * http://opensource.org/licenses/BSD-3-Clause */ -define('source-map/source-map-generator', ['require', 'exports', 'module' , 'source-map/base64-vlq', 'source-map/util', 'source-map/array-set'], function(require, exports, module) { +define('source-map/source-map-generator', ['require', 'exports', 'module' , 'source-map/base64-vlq', 'source-map/util', 'source-map/array-set', 'source-map/mapping-list'], function(require, exports, module) { var base64VLQ = require('./base64-vlq'); var util = require('./util'); var ArraySet = require('./array-set').ArraySet; + var MappingList = require('./mapping-list').MappingList; /** * An instance of the SourceMapGenerator represents a source map which is * being built incrementally. You may pass an object with the following * properties: * * - file: The filename of the generated source. * - sourceRoot: A root for all relative URLs in this source map. */ function SourceMapGenerator(aArgs) { if (!aArgs) { aArgs = {}; } this._file = util.getArg(aArgs, 'file', null); this._sourceRoot = util.getArg(aArgs, 'sourceRoot', null); + this._skipValidation = util.getArg(aArgs, 'skipValidation', false); this._sources = new ArraySet(); this._names = new ArraySet(); - this._mappings = []; + this._mappings = new MappingList(); this._sourcesContents = null; } SourceMapGenerator.prototype._version = 3; /** * Creates a new SourceMapGenerator based on a SourceMapConsumer * @@ -243,27 +245,29 @@ define('source-map/source-map-generator' */ SourceMapGenerator.prototype.addMapping = function SourceMapGenerator_addMapping(aArgs) { var generated = util.getArg(aArgs, 'generated'); var original = util.getArg(aArgs, 'original', null); var source = util.getArg(aArgs, 'source', null); var name = util.getArg(aArgs, 'name', null); - this._validateMapping(generated, original, source, name); + if (!this._skipValidation) { + this._validateMapping(generated, original, source, name); + } if (source != null && !this._sources.has(source)) { this._sources.add(source); } if (name != null && !this._names.has(name)) { this._names.add(name); } - this._mappings.push({ + this._mappings.add({ generatedLine: generated.line, generatedColumn: generated.column, originalLine: original != null && original.line, originalColumn: original != null && original.column, source: source, name: name }); }; @@ -330,17 +334,17 @@ define('source-map/source-map-generator' sourceFile = util.relative(sourceRoot, sourceFile); } // Applying the SourceMap can add and remove items from the sources and // the names array. var newSources = new ArraySet(); var newNames = new ArraySet(); // Find mappings for the "sourceFile" - this._mappings.forEach(function (mapping) { + this._mappings.unsortedForEach(function (mapping) { if (mapping.source === sourceFile && mapping.originalLine != null) { // Check if it can be mapped by the source map, then update the mapping. var original = aSourceMapConsumer.originalPositionFor({ line: mapping.originalLine, column: mapping.originalColumn }); if (original.source != null) { // Copy mapping @@ -436,36 +440,31 @@ define('source-map/source-map-generator' var previousGeneratedLine = 1; var previousOriginalColumn = 0; var previousOriginalLine = 0; var previousName = 0; var previousSource = 0; var result = ''; var mapping; - // The mappings must be guaranteed to be in sorted order before we start - // serializing them or else the generated line numbers (which are defined - // via the ';' separators) will be all messed up. Note: it might be more - // performant to maintain the sorting as we insert them, rather than as we - // serialize them, but the big O is the same either way. - this._mappings.sort(util.compareByGeneratedPositions); + var mappings = this._mappings.toArray(); - for (var i = 0, len = this._mappings.length; i < len; i++) { - mapping = this._mappings[i]; + for (var i = 0, len = mappings.length; i < len; i++) { + mapping = mappings[i]; if (mapping.generatedLine !== previousGeneratedLine) { previousGeneratedColumn = 0; while (mapping.generatedLine !== previousGeneratedLine) { result += ';'; previousGeneratedLine++; } } else { if (i > 0) { - if (!util.compareByGeneratedPositions(mapping, this._mappings[i - 1])) { + if (!util.compareByGeneratedPositions(mapping, mappings[i - 1])) { continue; } result += ','; } } result += base64VLQ.encode(mapping.generatedColumn - previousGeneratedColumn); @@ -537,17 +536,17 @@ define('source-map/source-map-generator' return map; }; /** * Render the source map being generated to a string. */ SourceMapGenerator.prototype.toString = function SourceMapGenerator_toString() { - return JSON.stringify(this); + return JSON.stringify(this.toJSON()); }; exports.SourceMapGenerator = SourceMapGenerator; }); /* -*- Mode: js; js-indent-level: 2; -*- */ /* * Copyright 2011 Mozilla Foundation and contributors @@ -608,29 +607,29 @@ define('source-map/base64-vlq', ['requir // binary: 011111 var VLQ_BASE_MASK = VLQ_BASE - 1; // binary: 100000 var VLQ_CONTINUATION_BIT = VLQ_BASE; /** * Converts from a two-complement value to a value where the sign bit is - * is placed in the least significant bit. For example, as decimals: + * placed in the least significant bit. For example, as decimals: * 1 becomes 2 (10 binary), -1 becomes 3 (11 binary) * 2 becomes 4 (100 binary), -2 becomes 5 (101 binary) */ function toVLQSigned(aValue) { return aValue < 0 ? ((-aValue) << 1) + 1 : (aValue << 1) + 0; } /** * Converts to a two-complement value from a value where the sign bit is - * is placed in the least significant bit. For example, as decimals: + * placed in the least significant bit. For example, as decimals: * 2 (10 binary) becomes 1, 3 (11 binary) becomes -1 * 4 (100 binary) becomes 2, 5 (101 binary) becomes -2 */ function fromVLQSigned(aValue) { var isNegative = (aValue & 1) === 1; var shifted = aValue >> 1; return isNegative ? -shifted @@ -659,36 +658,35 @@ define('source-map/base64-vlq', ['requir return encoded; }; /** * Decodes the next base 64 VLQ value from the given string and returns the * value and the rest of the string via the out parameter. */ - exports.decode = function base64VLQ_decode(aStr, aOutParam) { - var i = 0; + exports.decode = function base64VLQ_decode(aStr, aIndex, aOutParam) { var strLen = aStr.length; var result = 0; var shift = 0; var continuation, digit; do { - if (i >= strLen) { + if (aIndex >= strLen) { throw new Error("Expected more digits in base 64 VLQ value."); } - digit = base64.decode(aStr.charAt(i++)); + digit = base64.decode(aStr.charAt(aIndex++)); continuation = !!(digit & VLQ_CONTINUATION_BIT); digit &= VLQ_BASE_MASK; result = result + (digit << shift); shift += VLQ_BASE_SHIFT; } while (continuation); aOutParam.value = fromVLQSigned(result); - aOutParam.rest = aStr.slice(i); + aOutParam.rest = aIndex; }; }); /* -*- Mode: js; js-indent-level: 2; -*- */ /* * Copyright 2011 Mozilla Foundation and contributors * Licensed under the New BSD license. See LICENSE or: * http://opensource.org/licenses/BSD-3-Clause @@ -981,27 +979,27 @@ define('source-map/util', ['require', 'e return cmp; } cmp = mappingA.originalColumn - mappingB.originalColumn; if (cmp || onlyCompareOriginal) { return cmp; } - cmp = strcmp(mappingA.name, mappingB.name); + cmp = mappingA.generatedColumn - mappingB.generatedColumn; if (cmp) { return cmp; } cmp = mappingA.generatedLine - mappingB.generatedLine; if (cmp) { return cmp; } - return mappingA.generatedColumn - mappingB.generatedColumn; + return strcmp(mappingA.name, mappingB.name); }; exports.compareByOriginalPositions = compareByOriginalPositions; /** * Comparator between two mappings where the generated positions are * compared. * * Optionally pass in `true` as `onlyCompareGenerated` to consider two @@ -1133,139 +1131,130 @@ define('source-map/array-set', ['require return this._array.slice(); }; exports.ArraySet = ArraySet; }); /* -*- Mode: js; js-indent-level: 2; -*- */ /* + * Copyright 2014 Mozilla Foundation and contributors + * Licensed under the New BSD license. See LICENSE or: + * http://opensource.org/licenses/BSD-3-Clause + */ +define('source-map/mapping-list', ['require', 'exports', 'module' , 'source-map/util'], function(require, exports, module) { + + var util = require('./util'); + + /** + * Determine whether mappingB is after mappingA with respect to generated + * position. + */ + function generatedPositionAfter(mappingA, mappingB) { + // Optimized for most common case + var lineA = mappingA.generatedLine; + var lineB = mappingB.generatedLine; + var columnA = mappingA.generatedColumn; + var columnB = mappingB.generatedColumn; + return lineB > lineA || lineB == lineA && columnB >= columnA || + util.compareByGeneratedPositions(mappingA, mappingB) <= 0; + } + + /** + * A data structure to provide a sorted view of accumulated mappings in a + * performance conscious manner. It trades a neglibable overhead in general + * case for a large speedup in case of mappings being added in order. + */ + function MappingList() { + this._array = []; + this._sorted = true; + // Serves as infimum + this._last = {generatedLine: -1, generatedColumn: 0}; + } + + /** + * Iterate through internal items. This method takes the same arguments that + * `Array.prototype.forEach` takes. + * + * NOTE: The order of the mappings is NOT guaranteed. + */ + MappingList.prototype.unsortedForEach = + function MappingList_forEach(aCallback, aThisArg) { + this._array.forEach(aCallback, aThisArg); + }; + + /** + * Add the given source mapping. + * + * @param Object aMapping + */ + MappingList.prototype.add = function MappingList_add(aMapping) { + var mapping; + if (generatedPositionAfter(this._last, aMapping)) { + this._last = aMapping; + this._array.push(aMapping); + } else { + this._sorted = false; + this._array.push(aMapping); + } + }; + + /** + * Returns the flat, sorted array of mappings. The mappings are sorted by + * generated position. + * + * WARNING: This method returns internal data without copying, for + * performance. The return value must NOT be mutated, and should be treated as + * an immutable borrow. If you want to take ownership, you must make your own + * copy. + */ + MappingList.prototype.toArray = function MappingList_toArray() { + if (!this._sorted) { + this._array.sort(util.compareByGeneratedPositions); + this._sorted = true; + } + return this._array; + }; + + exports.MappingList = MappingList; + +}); +/* -*- Mode: js; js-indent-level: 2; -*- */ +/* * Copyright 2011 Mozilla Foundation and contributors * Licensed under the New BSD license. See LICENSE or: * http://opensource.org/licenses/BSD-3-Clause */ define('source-map/source-map-consumer', ['require', 'exports', 'module' , 'source-map/util', 'source-map/binary-search', 'source-map/array-set', 'source-map/base64-vlq'], function(require, exports, module) { var util = require('./util'); var binarySearch = require('./binary-search'); var ArraySet = require('./array-set').ArraySet; var base64VLQ = require('./base64-vlq'); - /** - * A SourceMapConsumer instance represents a parsed source map which we can - * query for information about the original file positions by giving it a file - * position in the generated source. - * - * The only parameter is the raw source map (either as a JSON string, or - * already parsed to an object). According to the spec, source maps have the - * following attributes: - * - * - version: Which version of the source map spec this map is following. - * - sources: An array of URLs to the original source files. - * - names: An array of identifiers which can be referrenced by individual mappings. - * - sourceRoot: Optional. The URL root from which all sources are relative. - * - sourcesContent: Optional. An array of contents of the original source files. - * - mappings: A string of base64 VLQs which contain the actual mappings. - * - file: Optional. The generated file this source map is associated with. - * - * Here is an example source map, taken from the source map spec[0]: - * - * { - * version : 3, - * file: "out.js", - * sourceRoot : "", - * sources: ["foo.js", "bar.js"], - * names: ["src", "maps", "are", "fun"], - * mappings: "AA,AB;;ABCDE;" - * } - * - * [0]: https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit?pli=1# - */ function SourceMapConsumer(aSourceMap) { var sourceMap = aSourceMap; if (typeof aSourceMap === 'string') { sourceMap = JSON.parse(aSourceMap.replace(/^\)\]\}'/, '')); } - var version = util.getArg(sourceMap, 'version'); - var sources = util.getArg(sourceMap, 'sources'); - // Sass 3.3 leaves out the 'names' array, so we deviate from the spec (which - // requires the array) to play nice here. - var names = util.getArg(sourceMap, 'names', []); - var sourceRoot = util.getArg(sourceMap, 'sourceRoot', null); - var sourcesContent = util.getArg(sourceMap, 'sourcesContent', null); - var mappings = util.getArg(sourceMap, 'mappings'); - var file = util.getArg(sourceMap, 'file', null); - - // Once again, Sass deviates from the spec and supplies the version as a - // string rather than a number, so we use loose equality checking here. - if (version != this._version) { - throw new Error('Unsupported version: ' + version); - } - - // Some source maps produce relative source paths like "./foo.js" instead of - // "foo.js". Normalize these first so that future comparisons will succeed. - // See bugzil.la/1090768. - sources = sources.map(util.normalize); - - // Pass `true` below to allow duplicate names and sources. While source maps - // are intended to be compressed and deduplicated, the TypeScript compiler - // sometimes generates source maps with duplicates in them. See Github issue - // #72 and bugzil.la/889492. - this._names = ArraySet.fromArray(names, true); - this._sources = ArraySet.fromArray(sources, true); - - this.sourceRoot = sourceRoot; - this.sourcesContent = sourcesContent; - this._mappings = mappings; - this.file = file; + return sourceMap.sections != null + ? new IndexedSourceMapConsumer(sourceMap) + : new BasicSourceMapConsumer(sourceMap); } - /** - * Create a SourceMapConsumer from a SourceMapGenerator. - * - * @param SourceMapGenerator aSourceMap - * The source map that will be consumed. - * @returns SourceMapConsumer - */ - SourceMapConsumer.fromSourceMap = - function SourceMapConsumer_fromSourceMap(aSourceMap) { - var smc = Object.create(SourceMapConsumer.prototype); - - smc._names = ArraySet.fromArray(aSourceMap._names.toArray(), true); - smc._sources = ArraySet.fromArray(aSourceMap._sources.toArray(), true); - smc.sourceRoot = aSourceMap._sourceRoot; - smc.sourcesContent = aSourceMap._generateSourcesContent(smc._sources.toArray(), - smc.sourceRoot); - smc.file = aSourceMap._file; - - smc.__generatedMappings = aSourceMap._mappings.slice() - .sort(util.compareByGeneratedPositions); - smc.__originalMappings = aSourceMap._mappings.slice() - .sort(util.compareByOriginalPositions); - - return smc; - }; + SourceMapConsumer.fromSourceMap = function(aSourceMap) { + return BasicSourceMapConsumer.fromSourceMap(aSourceMap); + } /** * The version of the source mapping spec that we are consuming. */ SourceMapConsumer.prototype._version = 3; - /** - * The list of original sources. - */ - Object.defineProperty(SourceMapConsumer.prototype, 'sources', { - get: function () { - return this._sources.toArray().map(function (s) { - return this.sourceRoot != null ? util.join(this.sourceRoot, s) : s; - }, this); - } - }); - // `__generatedMappings` and `__originalMappings` are arrays that hold the // parsed mapping coordinates from the source map's "mappings" attribute. They // are lazily instantiated, accessed via the `_generatedMappings` and // `_originalMappings` getters respectively, and we only parse the mappings // and create these arrays once queried for a source location. We jump through // these hoops because there can be many thousands of mappings, and parsing // them is expensive, so we only want to do it if we must. // @@ -1313,354 +1302,37 @@ define('source-map/source-map-consumer', this._parseMappings(this._mappings, this.sourceRoot); } return this.__originalMappings; } }); SourceMapConsumer.prototype._nextCharIsMappingSeparator = - function SourceMapConsumer_nextCharIsMappingSeparator(aStr) { - var c = aStr.charAt(0); + function SourceMapConsumer_nextCharIsMappingSeparator(aStr, index) { + var c = aStr.charAt(index); return c === ";" || c === ","; }; /** * Parse the mappings in a string in to a data structure which we can easily * query (the ordered arrays in the `this.__generatedMappings` and * `this.__originalMappings` properties). */ SourceMapConsumer.prototype._parseMappings = function SourceMapConsumer_parseMappings(aStr, aSourceRoot) { - var generatedLine = 1; - var previousGeneratedColumn = 0; - var previousOriginalLine = 0; - var previousOriginalColumn = 0; - var previousSource = 0; - var previousName = 0; - var str = aStr; - var temp = {}; - var mapping; - - while (str.length > 0) { - if (str.charAt(0) === ';') { - generatedLine++; - str = str.slice(1); - previousGeneratedColumn = 0; - } - else if (str.charAt(0) === ',') { - str = str.slice(1); - } - else { - mapping = {}; - mapping.generatedLine = generatedLine; - - // Generated column. - base64VLQ.decode(str, temp); - mapping.generatedColumn = previousGeneratedColumn + temp.value; - previousGeneratedColumn = mapping.generatedColumn; - str = temp.rest; - - if (str.length > 0 && !this._nextCharIsMappingSeparator(str)) { - // Original source. - base64VLQ.decode(str, temp); - mapping.source = this._sources.at(previousSource + temp.value); - previousSource += temp.value; - str = temp.rest; - if (str.length === 0 || this._nextCharIsMappingSeparator(str)) { - throw new Error('Found a source, but no line and column'); - } - - // Original line. - base64VLQ.decode(str, temp); - mapping.originalLine = previousOriginalLine + temp.value; - previousOriginalLine = mapping.originalLine; - // Lines are stored 0-based - mapping.originalLine += 1; - str = temp.rest; - if (str.length === 0 || this._nextCharIsMappingSeparator(str)) { - throw new Error('Found a source and line, but no column'); - } - - // Original column. - base64VLQ.decode(str, temp); - mapping.originalColumn = previousOriginalColumn + temp.value; - previousOriginalColumn = mapping.originalColumn; - str = temp.rest; - - if (str.length > 0 && !this._nextCharIsMappingSeparator(str)) { - // Original name. - base64VLQ.decode(str, temp); - mapping.name = this._names.at(previousName + temp.value); - previousName += temp.value; - str = temp.rest; - } - } - - this.__generatedMappings.push(mapping); - if (typeof mapping.originalLine === 'number') { - this.__originalMappings.push(mapping); - } - } - } - - this.__generatedMappings.sort(util.compareByGeneratedPositions); - this.__originalMappings.sort(util.compareByOriginalPositions); - }; - - /** - * Find the mapping that best matches the hypothetical "needle" mapping that - * we are searching for in the given "haystack" of mappings. - */ - SourceMapConsumer.prototype._findMapping = - function SourceMapConsumer_findMapping(aNeedle, aMappings, aLineName, - aColumnName, aComparator) { - // To return the position we are searching for, we must first find the - // mapping for the given position and then return the opposite position it - // points to. Because the mappings are sorted, we can use binary search to - // find the best mapping. - - if (aNeedle[aLineName] <= 0) { - throw new TypeError('Line must be greater than or equal to 1, got ' - + aNeedle[aLineName]); - } - if (aNeedle[aColumnName] < 0) { - throw new TypeError('Column must be greater than or equal to 0, got ' - + aNeedle[aColumnName]); - } - - return binarySearch.search(aNeedle, aMappings, aComparator); - }; - - /** - * Compute the last column for each generated mapping. The last column is - * inclusive. - */ - SourceMapConsumer.prototype.computeColumnSpans = - function SourceMapConsumer_computeColumnSpans() { - for (var index = 0; index < this._generatedMappings.length; ++index) { - var mapping = this._generatedMappings[index]; - - // Mappings do not contain a field for the last generated columnt. We - // can come up with an optimistic estimate, however, by assuming that - // mappings are contiguous (i.e. given two consecutive mappings, the - // first mapping ends where the second one starts). - if (index + 1 < this._generatedMappings.length) { - var nextMapping = this._generatedMappings[index + 1]; - - if (mapping.generatedLine === nextMapping.generatedLine) { - mapping.lastGeneratedColumn = nextMapping.generatedColumn - 1; - continue; - } - } - - // The last mapping for each line spans the entire line. - mapping.lastGeneratedColumn = Infinity; - } - }; - - /** - * Returns the original source, line, and column information for the generated - * source's line and column positions provided. The only argument is an object - * with the following properties: - * - * - line: The line number in the generated source. - * - column: The column number in the generated source. - * - * and an object is returned with the following properties: - * - * - source: The original source file, or null. - * - line: The line number in the original source, or null. - * - column: The column number in the original source, or null. - * - name: The original identifier, or null. - */ - SourceMapConsumer.prototype.originalPositionFor = - function SourceMapConsumer_originalPositionFor(aArgs) { - var needle = { - generatedLine: util.getArg(aArgs, 'line'), - generatedColumn: util.getArg(aArgs, 'column') - }; - - var index = this._findMapping(needle, - this._generatedMappings, - "generatedLine", - "generatedColumn", - util.compareByGeneratedPositions); - - if (index >= 0) { - var mapping = this._generatedMappings[index]; - - if (mapping.generatedLine === needle.generatedLine) { - var source = util.getArg(mapping, 'source', null); - if (source != null && this.sourceRoot != null) { - source = util.join(this.sourceRoot, source); - } - return { - source: source, - line: util.getArg(mapping, 'originalLine', null), - column: util.getArg(mapping, 'originalColumn', null), - name: util.getArg(mapping, 'name', null) - }; - } - } - - return { - source: null, - line: null, - column: null, - name: null - }; - }; - - /** - * Returns the original source content. The only argument is the url of the - * original source file. Returns null if no original source content is - * availible. - */ - SourceMapConsumer.prototype.sourceContentFor = - function SourceMapConsumer_sourceContentFor(aSource) { - if (!this.sourcesContent) { - return null; - } - - if (this.sourceRoot != null) { - aSource = util.relative(this.sourceRoot, aSource); - } - - if (this._sources.has(aSource)) { - return this.sourcesContent[this._sources.indexOf(aSource)]; - } - - var url; - if (this.sourceRoot != null - && (url = util.urlParse(this.sourceRoot))) { - // XXX: file:// URIs and absolute paths lead to unexpected behavior for - // many users. We can help them out when they expect file:// URIs to - // behave like it would if they were running a local HTTP server. See - // https://bugzilla.mozilla.org/show_bug.cgi?id=885597. - var fileUriAbsPath = aSource.replace(/^file:\/\//, ""); - if (url.scheme == "file" - && this._sources.has(fileUriAbsPath)) { - return this.sourcesContent[this._sources.indexOf(fileUriAbsPath)] - } - - if ((!url.path || url.path == "/") - && this._sources.has("/" + aSource)) { - return this.sourcesContent[this._sources.indexOf("/" + aSource)]; - } - } - - throw new Error('"' + aSource + '" is not in the SourceMap.'); - }; - - /** - * Returns the generated line and column information for the original source, - * line, and column positions provided. The only argument is an object with - * the following properties: - * - * - source: The filename of the original source. - * - line: The line number in the original source. - * - column: The column number in the original source. - * - * and an object is returned with the following properties: - * - * - line: The line number in the generated source, or null. - * - column: The column number in the generated source, or null. - */ - SourceMapConsumer.prototype.generatedPositionFor = - function SourceMapConsumer_generatedPositionFor(aArgs) { - var needle = { - source: util.getArg(aArgs, 'source'), - originalLine: util.getArg(aArgs, 'line'), - originalColumn: util.getArg(aArgs, 'column') - }; - - if (this.sourceRoot != null) { - needle.source = util.relative(this.sourceRoot, needle.source); - } - - var index = this._findMapping(needle, - this._originalMappings, - "originalLine", - "originalColumn", - util.compareByOriginalPositions); - - if (index >= 0) { - var mapping = this._originalMappings[index]; - - return { - line: util.getArg(mapping, 'generatedLine', null), - column: util.getArg(mapping, 'generatedColumn', null), - lastColumn: util.getArg(mapping, 'lastGeneratedColumn', null) - }; - } - - return { - line: null, - column: null, - lastColumn: null - }; - }; - - /** - * Returns all generated line and column information for the original source - * and line provided. The only argument is an object with the following - * properties: - * - * - source: The filename of the original source. - * - line: The line number in the original source. - * - * and an array of objects is returned, each with the following properties: - * - * - line: The line number in the generated source, or null. - * - column: The column number in the generated source, or null. - */ - SourceMapConsumer.prototype.allGeneratedPositionsFor = - function SourceMapConsumer_allGeneratedPositionsFor(aArgs) { - // When there is no exact match, SourceMapConsumer.prototype._findMapping - // returns the index of the closest mapping less than the needle. By - // setting needle.originalColumn to Infinity, we thus find the last - // mapping for the given line, provided such a mapping exists. - var needle = { - source: util.getArg(aArgs, 'source'), - originalLine: util.getArg(aArgs, 'line'), - originalColumn: Infinity - }; - - if (this.sourceRoot != null) { - needle.source = util.relative(this.sourceRoot, needle.source); - } - - var mappings = []; - - var index = this._findMapping(needle, - this._originalMappings, - "originalLine", - "originalColumn", - util.compareByOriginalPositions); - if (index >= 0) { - var mapping = this._originalMappings[index]; - - while (mapping && mapping.originalLine === needle.originalLine) { - mappings.push({ - line: util.getArg(mapping, 'generatedLine', null), - column: util.getArg(mapping, 'generatedColumn', null), - lastColumn: util.getArg(mapping, 'lastGeneratedColumn', null) - }); - - mapping = this._originalMappings[--index]; - } - } - - return mappings.reverse(); + throw new Error("Subclasses must implement _parseMappings"); }; SourceMapConsumer.GENERATED_ORDER = 1; SourceMapConsumer.ORIGINAL_ORDER = 2; + SourceMapConsumer.GREATEST_LOWER_BOUND = 1; + SourceMapConsumer.LEAST_UPPER_BOUND = 2; + /** * Iterate over each mapping between an original source/line/column and a * generated line/column in this source map. * * @param Function aCallback * The function that is called with each mapping. * @param Object aContext * Optional. If specified, this object will be the value of `this` every @@ -1701,93 +1373,921 @@ define('source-map/source-map-consumer', generatedColumn: mapping.generatedColumn, originalLine: mapping.originalLine, originalColumn: mapping.originalColumn, name: mapping.name }; }).forEach(aCallback, context); }; + /** + * Returns all generated line and column information for the original source, + * line, and column provided. If no column is provided, returns all mappings + * corresponding to a single line. Otherwise, returns all mappings + * corresponding to a single line and column. + * + * The only argument is an object with the following properties: + * + * - source: The filename of the original source. + * - line: The line number in the original source. + * - column: Optional. the column number in the original source. + * + * and an array of objects is returned, each with the following properties: + * + * - line: The line number in the generated source, or null. + * - column: The column number in the generated source, or null. + */ + SourceMapConsumer.prototype.allGeneratedPositionsFor = + function SourceMapConsumer_allGeneratedPositionsFor(aArgs) { + // When there is no exact match, BasicSourceMapConsumer.prototype._findMapping + // returns the index of the closest mapping less than the needle. By + // setting needle.originalColumn to 0, we thus find the last mapping for + // the given line, provided such a mapping exists. + var needle = { + source: util.getArg(aArgs, 'source'), + originalLine: util.getArg(aArgs, 'line'), + originalColumn: util.getArg(aArgs, 'column', 0) + }; + + if (this.sourceRoot != null) { + needle.source = util.relative(this.sourceRoot, needle.source); + } + + var mappings = []; + + var index = this._findMapping(needle, + this._originalMappings, + "originalLine", + "originalColumn", + util.compareByOriginalPositions, + binarySearch.LEAST_UPPER_BOUND); + if (index >= 0) { + var mapping = this._originalMappings[index]; + var originalLine = mapping.originalLine; + var originalColumn = mapping.originalColumn; + + // Iterate until either we run out of mappings, or we run into + // a mapping for a different line. Since mappings are sorted, this is + // guaranteed to find all mappings for the line we are searching for. + while (mapping && mapping.originalLine === originalLine && + (aArgs.column === undefined || + mapping.originalColumn === originalColumn)) { + mappings.push({ + line: util.getArg(mapping, 'generatedLine', null), + column: util.getArg(mapping, 'generatedColumn', null), + lastColumn: util.getArg(mapping, 'lastGeneratedColumn', null) + }); + + mapping = this._originalMappings[++index]; + } + } + + return mappings; + }; + exports.SourceMapConsumer = SourceMapConsumer; + /** + * A BasicSourceMapConsumer instance represents a parsed source map which we can + * query for information about the original file positions by giving it a file + * position in the generated source. + * + * The only parameter is the raw source map (either as a JSON string, or + * already parsed to an object). According to the spec, source maps have the + * following attributes: + * + * - version: Which version of the source map spec this map is following. + * - sources: An array of URLs to the original source files. + * - names: An array of identifiers which can be referrenced by individual mappings. + * - sourceRoot: Optional. The URL root from which all sources are relative. + * - sourcesContent: Optional. An array of contents of the original source files. + * - mappings: A string of base64 VLQs which contain the actual mappings. + * - file: Optional. The generated file this source map is associated with. + * + * Here is an example source map, taken from the source map spec[0]: + * + * { + * version : 3, + * file: "out.js", + * sourceRoot : "", + * sources: ["foo.js", "bar.js"], + * names: ["src", "maps", "are", "fun"], + * mappings: "AA,AB;;ABCDE;" + * } + * + * [0]: https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit?pli=1# + */ + function BasicSourceMapConsumer(aSourceMap) { + var sourceMap = aSourceMap; + if (typeof aSourceMap === 'string') { + sourceMap = JSON.parse(aSourceMap.replace(/^\)\]\}'/, '')); + } + + var version = util.getArg(sourceMap, 'version'); + var sources = util.getArg(sourceMap, 'sources'); + // Sass 3.3 leaves out the 'names' array, so we deviate from the spec (which + // requires the array) to play nice here. + var names = util.getArg(sourceMap, 'names', []); + var sourceRoot = util.getArg(sourceMap, 'sourceRoot', null); + var sourcesContent = util.getArg(sourceMap, 'sourcesContent', null); + var mappings = util.getArg(sourceMap, 'mappings'); + var file = util.getArg(sourceMap, 'file', null); + + // Once again, Sass deviates from the spec and supplies the version as a + // string rather than a number, so we use loose equality checking here. + if (version != this._version) { + throw new Error('Unsupported version: ' + version); + } + + // Some source maps produce relative source paths like "./foo.js" instead of + // "foo.js". Normalize these first so that future comparisons will succeed. + // See bugzil.la/1090768. + sources = sources.map(util.normalize); + + // Pass `true` below to allow duplicate names and sources. While source maps + // are intended to be compressed and deduplicated, the TypeScript compiler + // sometimes generates source maps with duplicates in them. See Github issue + // #72 and bugzil.la/889492. + this._names = ArraySet.fromArray(names, true); + this._sources = ArraySet.fromArray(sources, true); + + this.sourceRoot = sourceRoot; + this.sourcesContent = sourcesContent; + this._mappings = mappings; + this.file = file; + } + + BasicSourceMapConsumer.prototype = Object.create(SourceMapConsumer.prototype); + BasicSourceMapConsumer.prototype.consumer = SourceMapConsumer; + + /** + * Create a BasicSourceMapConsumer from a SourceMapGenerator. + * + * @param SourceMapGenerator aSourceMap + * The source map that will be consumed. + * @returns BasicSourceMapConsumer + */ + BasicSourceMapConsumer.fromSourceMap = + function SourceMapConsumer_fromSourceMap(aSourceMap) { + var smc = Object.create(BasicSourceMapConsumer.prototype); + + smc._names = ArraySet.fromArray(aSourceMap._names.toArray(), true); + smc._sources = ArraySet.fromArray(aSourceMap._sources.toArray(), true); + smc.sourceRoot = aSourceMap._sourceRoot; + smc.sourcesContent = aSourceMap._generateSourcesContent(smc._sources.toArray(), + smc.sourceRoot); + smc.file = aSourceMap._file; + + smc.__generatedMappings = aSourceMap._mappings.toArray().slice(); + smc.__originalMappings = aSourceMap._mappings.toArray().slice() + .sort(util.compareByOriginalPositions); + + return smc; + }; + + /** + * The version of the source mapping spec that we are consuming. + */ + BasicSourceMapConsumer.prototype._version = 3; + + /** + * The list of original sources. + */ + Object.defineProperty(BasicSourceMapConsumer.prototype, 'sources', { + get: function () { + return this._sources.toArray().map(function (s) { + return this.sourceRoot != null ? util.join(this.sourceRoot, s) : s; + }, this); + } + }); + + /** + * Parse the mappings in a string in to a data structure which we can easily + * query (the ordered arrays in the `this.__generatedMappings` and + * `this.__originalMappings` properties). + */ + BasicSourceMapConsumer.prototype._parseMappings = + function SourceMapConsumer_parseMappings(aStr, aSourceRoot) { + var generatedLine = 1; + var previousGeneratedColumn = 0; + var previousOriginalLine = 0; + var previousOriginalColumn = 0; + var previousSource = 0; + var previousName = 0; + var length = aStr.length; + var index = 0; + var cachedValues = {}; + var temp = {}; + var mapping, str, values, end, value; + + while (index < length) { + if (aStr.charAt(index) === ';') { + generatedLine++; + ++index; + previousGeneratedColumn = 0; + } + else if (aStr.charAt(index) === ',') { + ++index; + } + else { + mapping = {}; + mapping.generatedLine = generatedLine; + + // Because each offset is encoded relative to the previous one, + // many segments often have the same encoding. We can exploit this + // fact by caching the parsed variable length fields of each segment, + // allowing us to avoid a second parse if we encounter the same + // segment again. + for (end = index; end < length; ++end) { + if (this._nextCharIsMappingSeparator(aStr, end)) { + break; + } + } + str = aStr.slice(index, end); + + values = cachedValues[str]; + if (values) { + index += str.length; + } else { + values = []; + while (index < end) { + base64VLQ.decode(aStr, index, temp); + value = temp.value; + index = temp.rest; + values.push(value); + } + cachedValues[str] = values; + } + + // Generated column. + mapping.generatedColumn = previousGeneratedColumn + values[0]; + previousGeneratedColumn = mapping.generatedColumn; + + if (values.length > 1) { + // Original source. + mapping.source = this._sources.at(previousSource + values[1]); + previousSource += values[1]; + if (values.length === 2) { + throw new Error('Found a source, but no line and column'); + } + + // Original line. + mapping.originalLine = previousOriginalLine + values[2]; + previousOriginalLine = mapping.originalLine; + // Lines are stored 0-based + mapping.originalLine += 1; + if (values.length === 3) { + throw new Error('Found a source and line, but no column'); + } + + // Original column. + mapping.originalColumn = previousOriginalColumn + values[3]; + previousOriginalColumn = mapping.originalColumn; + + if (values.length > 4) { + // Original name. + mapping.name = this._names.at(previousName + values[4]); + previousName += values[4]; + } + } + + this.__generatedMappings.push(mapping); + if (typeof mapping.originalLine === 'number') { + this.__originalMappings.push(mapping); + } + } + } + + this.__generatedMappings.sort(util.compareByGeneratedPositions); + this.__originalMappings.sort(util.compareByOriginalPositions); + }; + + /** + * Find the mapping that best matches the hypothetical "needle" mapping that + * we are searching for in the given "haystack" of mappings. + */ + BasicSourceMapConsumer.prototype._findMapping = + function SourceMapConsumer_findMapping(aNeedle, aMappings, aLineName, + aColumnName, aComparator, aBias) { + // To return the position we are searching for, we must first find the + // mapping for the given position and then return the opposite position it + // points to. Because the mappings are sorted, we can use binary search to + // find the best mapping. + + if (aNeedle[aLineName] <= 0) { + throw new TypeError('Line must be greater than or equal to 1, got ' + + aNeedle[aLineName]); + } + if (aNeedle[aColumnName] < 0) { + throw new TypeError('Column must be greater than or equal to 0, got ' + + aNeedle[aColumnName]); + } + + return binarySearch.search(aNeedle, aMappings, aComparator, aBias); + }; + + /** + * Compute the last column for each generated mapping. The last column is + * inclusive. + */ + BasicSourceMapConsumer.prototype.computeColumnSpans = + function SourceMapConsumer_computeColumnSpans() { + for (var index = 0; index < this._generatedMappings.length; ++index) { + var mapping = this._generatedMappings[index]; + + // Mappings do not contain a field for the last generated columnt. We + // can come up with an optimistic estimate, however, by assuming that + // mappings are contiguous (i.e. given two consecutive mappings, the + // first mapping ends where the second one starts). + if (index + 1 < this._generatedMappings.length) { + var nextMapping = this._generatedMappings[index + 1]; + + if (mapping.generatedLine === nextMapping.generatedLine) { + mapping.lastGeneratedColumn = nextMapping.generatedColumn - 1; + continue; + } + } + + // The last mapping for each line spans the entire line. + mapping.lastGeneratedColumn = Infinity; + } + }; + + /** + * Returns the original source, line, and column information for the generated + * source's line and column positions provided. The only argument is an object + * with the following properties: + * + * - line: The line number in the generated source. + * - column: The column number in the generated source. + * - bias: Either 'SourceMapConsumer.GREATEST_LOWER_BOUND' or + * 'SourceMapConsumer.LEAST_UPPER_BOUND'. Specifies whether to return the + * closest element that is smaller than or greater than the one we are + * searching for, respectively, if the exact element cannot be found. + * Defaults to 'SourceMapConsumer.GREATEST_LOWER_BOUND'. + * + * and an object is returned with the following properties: + * + * - source: The original source file, or null. + * - line: The line number in the original source, or null. + * - column: The column number in the original source, or null. + * - name: The original identifier, or null. + */ + BasicSourceMapConsumer.prototype.originalPositionFor = + function SourceMapConsumer_originalPositionFor(aArgs) { + var needle = { + generatedLine: util.getArg(aArgs, 'line'), + generatedColumn: util.getArg(aArgs, 'column') + }; + + var index = this._findMapping( + needle, + this._generatedMappings, + "generatedLine", + "generatedColumn", + util.compareByGeneratedPositions, + util.getArg(aArgs, 'bias', SourceMapConsumer.GREATEST_LOWER_BOUND) + ); + + if (index >= 0) { + var mapping = this._generatedMappings[index]; + + if (mapping.generatedLine === needle.generatedLine) { + var source = util.getArg(mapping, 'source', null); + if (source != null && this.sourceRoot != null) { + source = util.join(this.sourceRoot, source); + } + return { + source: source, + line: util.getArg(mapping, 'originalLine', null), + column: util.getArg(mapping, 'originalColumn', null), + name: util.getArg(mapping, 'name', null) + }; + } + } + + return { + source: null, + line: null, + column: null, + name: null + }; + }; + + /** + * Returns the original source content. The only argument is the url of the + * original source file. Returns null if no original source content is + * availible. + */ + BasicSourceMapConsumer.prototype.sourceContentFor = + function SourceMapConsumer_sourceContentFor(aSource, nullOnMissing) { + if (!this.sourcesContent) { + return null; + } + + if (this.sourceRoot != null) { + aSource = util.relative(this.sourceRoot, aSource); + } + + if (this._sources.has(aSource)) { + return this.sourcesContent[this._sources.indexOf(aSource)]; + } + + var url; + if (this.sourceRoot != null + && (url = util.urlParse(this.sourceRoot))) { + // XXX: file:// URIs and absolute paths lead to unexpected behavior for + // many users. We can help them out when they expect file:// URIs to + // behave like it would if they were running a local HTTP server. See + // https://bugzilla.mozilla.org/show_bug.cgi?id=885597. + var fileUriAbsPath = aSource.replace(/^file:\/\//, ""); + if (url.scheme == "file" + && this._sources.has(fileUriAbsPath)) { + return this.sourcesContent[this._sources.indexOf(fileUriAbsPath)] + } + + if ((!url.path || url.path == "/") + && this._sources.has("/" + aSource)) { + return this.sourcesContent[this._sources.indexOf("/" + aSource)]; + } + } + + // This function is used recursively from + // IndexedSourceMapConsumer.prototype.sourceContentFor. In that case, we + // don't want to throw if we can't find the source - we just want to + // return null, so we provide a flag to exit gracefully. + if (nullOnMissing) { + return null; + } + else { + throw new Error('"' + aSource + '" is not in the SourceMap.'); + } + }; + + /** + * Returns the generated line and column information for the original source, + * line, and column positions provided. The only argument is an object with + * the following properties: + * + * - source: The filename of the original source. + * - line: The line number in the original source. + * - column: The column number in the original source. + * - bias: Either 'SourceMapConsumer.GREATEST_LOWER_BOUND' or + * 'SourceMapConsumer.LEAST_UPPER_BOUND'. Specifies whether to return the + * closest element that is smaller than or greater than the one we are + * searching for, respectively, if the exact element cannot be found. + * Defaults to 'SourceMapConsumer.GREATEST_LOWER_BOUND'. + * + * and an object is returned with the following properties: + * + * - line: The line number in the generated source, or null. + * - column: The column number in the generated source, or null. + */ + BasicSourceMapConsumer.prototype.generatedPositionFor = + function SourceMapConsumer_generatedPositionFor(aArgs) { + var needle = { + source: util.getArg(aArgs, 'source'), + originalLine: util.getArg(aArgs, 'line'), + originalColumn: util.getArg(aArgs, 'column') + }; + + if (this.sourceRoot != null) { + needle.source = util.relative(this.sourceRoot, needle.source); + } + + var index = this._findMapping( + needle, + this._originalMappings, + "originalLine", + "originalColumn", + util.compareByOriginalPositions, + util.getArg(aArgs, 'bias', SourceMapConsumer.GREATEST_LOWER_BOUND) + ); + + if (index >= 0) { + var mapping = this._originalMappings[index]; + + if (mapping.source === needle.source) { + return { + line: util.getArg(mapping, 'generatedLine', null), + column: util.getArg(mapping, 'generatedColumn', null), + lastColumn: util.getArg(mapping, 'lastGeneratedColumn', null) + }; + } + } + + return { + line: null, + column: null, + lastColumn: null + }; + }; + + exports.BasicSourceMapConsumer = BasicSourceMapConsumer; + + /** + * An IndexedSourceMapConsumer instance represents a parsed source map which + * we can query for information. It differs from BasicSourceMapConsumer in + * that it takes "indexed" source maps (i.e. ones with a "sections" field) as + * input. + * + * The only parameter is a raw source map (either as a JSON string, or already + * parsed to an object). According to the spec for indexed source maps, they + * have the following attributes: + * + * - version: Which version of the source map spec this map is following. + * - file: Optional. The generated file this source map is associated with. + * - sections: A list of section definitions. + * + * Each value under the "sections" field has two fields: + * - offset: The offset into the original specified at which this section + * begins to apply, defined as an object with a "line" and "column" + * field. + * - map: A source map definition. This source map could also be indexed, + * but doesn't have to be. + * + * Instead of the "map" field, it's also possible to have a "url" field + * specifying a URL to retrieve a source map from, but that's currently + * unsupported. + * + * Here's an example source map, taken from the source map spec[0], but + * modified to omit a section which uses the "url" field. + * + * { + * version : 3, + * file: "app.js", + * sections: [{ + * offset: {line:100, column:10}, + * map: { + * version : 3, + * file: "section.js", + * sources: ["foo.js", "bar.js"], + * names: ["src", "maps", "are", "fun"], + * mappings: "AAAA,E;;ABCDE;" + * } + * }], + * } + * + * [0]: https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit#heading=h.535es3xeprgt + */ + function IndexedSourceMapConsumer(aSourceMap) { + var sourceMap = aSourceMap; + if (typeof aSourceMap === 'string') { + sourceMap = JSON.parse(aSourceMap.replace(/^\)\]\}'/, '')); + } + + var version = util.getArg(sourceMap, 'version'); + var sections = util.getArg(sourceMap, 'sections'); + + if (version != this._version) { + throw new Error('Unsupported version: ' + version); + } + + var lastOffset = { + line: -1, + column: 0 + }; + this._sections = sections.map(function (s) { + if (s.url) { + // The url field will require support for asynchronicity. + // See https://github.com/mozilla/source-map/issues/16 + throw new Error('Support for url field in sections not implemented.'); + } + var offset = util.getArg(s, 'offset'); + var offsetLine = util.getArg(offset, 'line'); + var offsetColumn = util.getArg(offset, 'column'); + + if (offsetLine < lastOffset.line || + (offsetLine === lastOffset.line && offsetColumn < lastOffset.column)) { + throw new Error('Section offsets must be ordered and non-overlapping.'); + } + lastOffset = offset; + + return { + generatedOffset: { + // The offset fields are 0-based, but we use 1-based indices when + // encoding/decoding from VLQ. + generatedLine: offsetLine + 1, + generatedColumn: offsetColumn + 1 + }, + consumer: new SourceMapConsumer(util.getArg(s, 'map')) + } + }); + } + + IndexedSourceMapConsumer.prototype = Object.create(SourceMapConsumer.prototype); + IndexedSourceMapConsumer.prototype.constructor = SourceMapConsumer; + + /** + * The version of the source mapping spec that we are consuming. + */ + IndexedSourceMapConsumer.prototype._version = 3; + + /** + * The list of original sources. + */ + Object.defineProperty(IndexedSourceMapConsumer.prototype, 'sources', { + get: function () { + var sources = []; + for (var i = 0; i < this._sections.length; i++) { + for (var j = 0; j < this._sections[i].consumer.sources.length; j++) { + sources.push(this._sections[i].consumer.sources[j]); + } + }; + return sources; + } + }); + + /** + * Returns the original source, line, and column information for the generated + * source's line and column positions provided. The only argument is an object + * with the following properties: + * + * - line: The line number in the generated source. + * - column: The column number in the generated source. + * + * and an object is returned with the following properties: + * + * - source: The original source file, or null. + * - line: The line number in the original source, or null. + * - column: The column number in the original source, or null. + * - name: The original identifier, or null. + */ + IndexedSourceMapConsumer.prototype.originalPositionFor = + function IndexedSourceMapConsumer_originalPositionFor(aArgs) { + var needle = { + generatedLine: util.getArg(aArgs, 'line'), + generatedColumn: util.getArg(aArgs, 'column') + }; + + // Find the section containing the generated position we're trying to map + // to an original position. + var sectionIndex = binarySearch.search(needle, this._sections, + function(needle, section) { + var cmp = needle.generatedLine - section.generatedOffset.generatedLine; + if (cmp) { + return cmp; + } + + return (needle.generatedColumn - + section.generatedOffset.generatedColumn); + }); + var section = this._sections[sectionIndex]; + + if (!section) { + return { + source: null, + line: null, + column: null, + name: null + }; + } + + return section.consumer.originalPositionFor({ + line: needle.generatedLine - + (section.generatedOffset.generatedLine - 1), + column: needle.generatedColumn - + (section.generatedOffset.generatedLine === needle.generatedLine + ? section.generatedOffset.generatedColumn - 1 + : 0), + bias: aArgs.bias + }); + }; + + /** + * Returns the original source content. The only argument is the url of the + * original source file. Returns null if no original source content is + * available. + */ + IndexedSourceMapConsumer.prototype.sourceContentFor = + function IndexedSourceMapConsumer_sourceContentFor(aSource, nullOnMissing) { + for (var i = 0; i < this._sections.length; i++) { + var section = this._sections[i]; + + var content = section.consumer.sourceContentFor(aSource, true); + if (content) { + return content; + } + } + if (nullOnMissing) { + return null; + } + else { + throw new Error('"' + aSource + '" is not in the SourceMap.'); + } + }; + + /** + * Returns the generated line and column information for the original source, + * line, and column positions provided. The only argument is an object with + * the following properties: + * + * - source: The filename of the original source. + * - line: The line number in the original source. + * - column: The column number in the original source. + * + * and an object is returned with the following properties: + * + * - line: The line number in the generated source, or null. + * - column: The column number in the generated source, or null. + */ + IndexedSourceMapConsumer.prototype.generatedPositionFor = + function IndexedSourceMapConsumer_generatedPositionFor(aArgs) { + for (var i = 0; i < this._sections.length; i++) { + var section = this._sections[i]; + + // Only consider this section if the requested source is in the list of + // sources of the consumer. + if (section.consumer.sources.indexOf(util.getArg(aArgs, 'source')) === -1) { + continue; + } + var generatedPosition = section.consumer.generatedPositionFor(aArgs); + if (generatedPosition) { + var ret = { + line: generatedPosition.line + + (section.generatedOffset.generatedLine - 1), + column: generatedPosition.column + + (section.generatedOffset.generatedLine === generatedPosition.line + ? section.generatedOffset.generatedColumn - 1 + : 0) + }; + return ret; + } + } + + return { + line: null, + column: null + }; + }; + + /** + * Parse the mappings in a string in to a data structure which we can easily + * query (the ordered arrays in the `this.__generatedMappings` and + * `this.__originalMappings` properties). + */ + IndexedSourceMapConsumer.prototype._parseMappings = + function IndexedSourceMapConsumer_parseMappings(aStr, aSourceRoot) { + this.__generatedMappings = []; + this.__originalMappings = []; + for (var i = 0; i < this._sections.length; i++) { + var section = this._sections[i]; + var sectionMappings = section.consumer._generatedMappings; + for (var j = 0; j < sectionMappings.length; j++) { + var mapping = sectionMappings[i]; + + var source = mapping.source; + var sourceRoot = section.consumer.sourceRoot; + + if (source != null && sourceRoot != null) { + source = util.join(sourceRoot, source); + } + + // The mappings coming from the consumer for the section have + // generated positions relative to the start of the section, so we + // need to offset them to be relative to the start of the concatenated + // generated file. + var adjustedMapping = { + source: source, + generatedLine: mapping.generatedLine + + (section.generatedOffset.generatedLine - 1), + generatedColumn: mapping.column + + (section.generatedOffset.generatedLine === mapping.generatedLine) + ? section.generatedOffset.generatedColumn - 1 + : 0, + originalLine: mapping.originalLine, + originalColumn: mapping.originalColumn, + name: mapping.name + }; + + this.__generatedMappings.push(adjustedMapping); + if (typeof adjustedMapping.originalLine === 'number') { + this.__originalMappings.push(adjustedMapping); + } + }; + }; + + this.__generatedMappings.sort(util.compareByGeneratedPositions); + this.__originalMappings.sort(util.compareByOriginalPositions); + }; + + exports.IndexedSourceMapConsumer = IndexedSourceMapConsumer; + }); /* -*- Mode: js; js-indent-level: 2; -*- */ /* * Copyright 2011 Mozilla Foundation and contributors * Licensed under the New BSD license. See LICENSE or: * http://opensource.org/licenses/BSD-3-Clause */ define('source-map/binary-search', ['require', 'exports', 'module' , ], function(require, exports, module) { + exports.GREATEST_LOWER_BOUND = 1; + exports.LEAST_UPPER_BOUND = 2; + /** * Recursive implementation of binary search. * * @param aLow Indices here and lower do not contain the needle. * @param aHigh Indices here and higher do not contain the needle. * @param aNeedle The element being searched for. * @param aHaystack The non-empty array being searched. * @param aCompare Function which takes two elements and returns -1, 0, or 1. + * @param aBias Either 'binarySearch.GREATEST_LOWER_BOUND' or + * 'binarySearch.LEAST_UPPER_BOUND'. Specifies whether to return the + * closest element that is smaller than or greater than the one we are + * searching for, respectively, if the exact element cannot be found. */ - function recursiveSearch(aLow, aHigh, aNeedle, aHaystack, aCompare) { + function recursiveSearch(aLow, aHigh, aNeedle, aHaystack, aCompare, aBias) { // This function terminates when one of the following is true: // // 1. We find the exact element we are looking for. // // 2. We did not find the exact element, but we can return the index of - // the next closest element that is less than that element. + // the next-closest element. // // 3. We did not find the exact element, and there is no next-closest - // element which is less than the one we are searching for, so we - // return -1. + // element than the one we are searching for, so we return -1. var mid = Math.floor((aHigh - aLow) / 2) + aLow; var cmp = aCompare(aNeedle, aHaystack[mid], true); if (cmp === 0) { // Found the element we are looking for. return mid; } else if (cmp > 0) { - // aHaystack[mid] is greater than our needle. + // Our needle is greater than aHaystack[mid]. if (aHigh - mid > 1) { // The element is in the upper half. - return recursiveSearch(mid, aHigh, aNeedle, aHaystack, aCompare); + return recursiveSearch(mid, aHigh, aNeedle, aHaystack, aCompare, aBias); } - // We did not find an exact match, return the next closest one - // (termination case 2). - return mid; + + // The exact needle element was not found in this haystack. Determine if + // we are in termination case (3) or (2) and return the appropriate thing. + if (aBias == exports.LEAST_UPPER_BOUND) { + return aHigh < aHaystack.length ? aHigh : -1; + } else { + return mid; + } } else { - // aHaystack[mid] is less than our needle. + // Our needle is less than aHaystack[mid]. if (mid - aLow > 1) { // The element is in the lower half. - return recursiveSearch(aLow, mid, aNeedle, aHaystack, aCompare); + return recursiveSearch(aLow, mid, aNeedle, aHaystack, aCompare, aBias); } - // The exact needle element was not found in this haystack. Determine if - // we are in termination case (2) or (3) and return the appropriate thing. - return aLow < 0 ? -1 : aLow; + + // we are in termination case (3) or (2) and return the appropriate thing. + if (aBias == exports.LEAST_UPPER_BOUND) { + return mid; + } else { + return aLow < 0 ? -1 : aLow; + } } } /** * This is an implementation of binary search which will always try and return - * the index of next lowest value checked if there is no exact hit. This is - * because mappings between original and generated line/col pairs are single - * points, and there is an implicit region between each of them, so a miss - * just means that you aren't on the very start of a region. + * the index of the closest element if there is no exact hit. This is because + * mappings between original and generated line/col pairs are single points, + * and there is an implicit region between each of them, so a miss just means + * that you aren't on the very start of a region. * * @param aNeedle The element you are looking for. * @param aHaystack The array that is being searched. * @param aCompare A function which takes the needle and an element in the * array and returns -1, 0, or 1 depending on whether the needle is less * than, equal to, or greater than the element, respectively. + * @param aBias Either 'binarySearch.GREATEST_LOWER_BOUND' or + * 'binarySearch.LEAST_UPPER_BOUND'. Specifies whether to return the + * closest element that is smaller than or greater than the one we are + * searching for, respectively, if the exact element cannot be found. + * Defaults to 'binarySearch.GREATEST_LOWER_BOUND'. */ - exports.search = function search(aNeedle, aHaystack, aCompare) { + exports.search = function search(aNeedle, aHaystack, aCompare, aBias) { if (aHaystack.length === 0) { return -1; } - return recursiveSearch(-1, aHaystack.length, aNeedle, aHaystack, aCompare) + + var index = recursiveSearch(-1, aHaystack.length, aNeedle, aHaystack, + aCompare, aBias || exports.GREATEST_LOWER_BOUND); + if (index < 0) { + return -1; + } + + // We have found either the exact element, or the next-closest element than + // the one we are searching for. However, there may be more than one such + // element. Make sure we always return the smallest of these. + while (index - 1 >= 0) { + if (aCompare(aHaystack[index], aHaystack[index - 1], true) !== 0) { + break; + } + --index; + } + + return index; }; }); /* -*- Mode: js; js-indent-level: 2; -*- */ /* * Copyright 2011 Mozilla Foundation and contributors * Licensed under the New BSD license. See LICENSE or: * http://opensource.org/licenses/BSD-3-Clause @@ -1796,18 +2296,23 @@ define('source-map/source-node', ['requi var SourceMapGenerator = require('./source-map-generator').SourceMapGenerator; var util = require('./util'); // Matches a Windows-style `\r\n` newline or a `\n` newline used by all other // operating systems these days (capturing the result). var REGEX_NEWLINE = /(\r?\n)/; - // Matches a Windows-style newline, or any character. - var REGEX_CHARACTER = /\r\n|[\s\S]/g; + // Newline character code for charCodeAt() comparisons + var NEWLINE_CODE = 10; + + // Private symbol for identifying `SourceNode`s when multiple versions of + // the source-map library are loaded. This MUST NOT CHANGE across + // versions! + var isSourceNode = "$$$isSourceNode$$$"; /** * SourceNodes provide a way to abstract over interpolating/concatenating * snippets of generated JavaScript source code while maintaining the line and * column information associated with the original source code. * * @param aLine The original line number. * @param aColumn The original column number. @@ -1818,16 +2323,17 @@ define('source-map/source-node', ['requi */ function SourceNode(aLine, aColumn, aSource, aChunks, aName) { this.children = []; this.sourceContents = {}; this.line = aLine == null ? null : aLine; this.column = aColumn == null ? null : aColumn; this.source = aSource == null ? null : aSource; this.name = aName == null ? null : aName; + this[isSourceNode] = true; if (aChunks != null) this.add(aChunks); } /** * Creates a SourceNode from generated code and a SourceMapConsumer. * * @param aGeneratedCode The generated code * @param aSourceMapConsumer The SourceMap for the generated code @@ -1948,17 +2454,17 @@ define('source-map/source-node', ['requi * SourceNode, or an array where each member is one of those things. */ SourceNode.prototype.add = function SourceNode_add(aChunk) { if (Array.isArray(aChunk)) { aChunk.forEach(function (chunk) { this.add(chunk); }, this); } - else if (aChunk instanceof SourceNode || typeof aChunk === "string") { + else if (aChunk[isSourceNode] || typeof aChunk === "string") { if (aChunk) { this.children.push(aChunk); } } else { throw new TypeError( "Expected a SourceNode, string, or an array of SourceNodes and strings. Got " + aChunk ); @@ -1973,17 +2479,17 @@ define('source-map/source-node', ['requi * SourceNode, or an array where each member is one of those things. */ SourceNode.prototype.prepend = function SourceNode_prepend(aChunk) { if (Array.isArray(aChunk)) { for (var i = aChunk.length-1; i >= 0; i--) { this.prepend(aChunk[i]); } } - else if (aChunk instanceof SourceNode || typeof aChunk === "string") { + else if (aChunk[isSourceNode] || typeof aChunk === "string") { this.children.unshift(aChunk); } else { throw new TypeError( "Expected a SourceNode, string, or an array of SourceNodes and strings. Got " + aChunk ); } return this; @@ -1995,17 +2501,17 @@ define('source-map/source-node', ['requi * snippet and the its original associated source's line/column location. * * @param aFn The traversal function. */ SourceNode.prototype.walk = function SourceNode_walk(aFn) { var chunk; for (var i = 0, len = this.children.length; i < len; i++) { chunk = this.children[i]; - if (chunk instanceof SourceNode) { + if (chunk[isSourceNode]) { chunk.walk(aFn); } else { if (chunk !== '') { aFn(chunk, { source: this.source, line: this.line, column: this.column, name: this.name }); @@ -2040,17 +2546,17 @@ define('source-map/source-node', ['requi * Call String.prototype.replace on the very right-most source snippet. Useful * for trimming whitespace from the end of a source node, etc. * * @param aPattern The pattern to replace. * @param aReplacement The thing to replace the pattern with. */ SourceNode.prototype.replaceRight = function SourceNode_replaceRight(aPattern, aReplacement) { var lastChild = this.children[this.children.length - 1]; - if (lastChild instanceof SourceNode) { + if (lastChild[isSourceNode]) { lastChild.replaceRight(aPattern, aReplacement); } else if (typeof lastChild === 'string') { this.children[this.children.length - 1] = lastChild.replace(aPattern, aReplacement); } else { this.children.push(''.replace(aPattern, aReplacement)); } @@ -2073,17 +2579,17 @@ define('source-map/source-node', ['requi * Walk over the tree of SourceNodes. The walking function is called for each * source file content and is passed the filename and source content. * * @param aFn The traversal function. */ SourceNode.prototype.walkSourceContents = function SourceNode_walkSourceContents(aFn) { for (var i = 0, len = this.children.length; i < len; i++) { - if (this.children[i] instanceof SourceNode) { + if (this.children[i][isSourceNode]) { this.children[i].walkSourceContents(aFn); } } var sources = Object.keys(this.sourceContents); for (var i = 0, len = sources.length; i < len; i++) { aFn(util.fromSetString(sources[i]), this.sourceContents[sources[i]]); } @@ -2149,22 +2655,22 @@ define('source-map/source-node', ['requi generated: { line: generated.line, column: generated.column } }); lastOriginalSource = null; sourceMappingActive = false; } - chunk.match(REGEX_CHARACTER).forEach(function (ch, idx, array) { - if (REGEX_NEWLINE.test(ch)) { + for (var idx = 0, length = chunk.length; idx < length; idx++) { + if (chunk.charCodeAt(idx) === NEWLINE_CODE) { generated.line++; generated.column = 0; // Mappings end at eol - if (idx + 1 === array.length) { + if (idx + 1 === length) { lastOriginalSource = null; sourceMappingActive = false; } else if (sourceMappingActive) { map.addMapping({ source: original.source, original: { line: original.line, column: original.column @@ -2172,19 +2678,19 @@ define('source-map/source-node', ['requi generated: { line: generated.line, column: generated.column }, name: original.name }); } } else { - generated.column += ch.length; + generated.column++; } - }); + } }); this.walkSourceContents(function (sourceFile, sourceContent) { map.setSourceContent(sourceFile, sourceContent); }); return { code: generated.code, map: map }; };
--- a/toolkit/devtools/sourcemap/tests/unit/Utils.jsm +++ b/toolkit/devtools/sourcemap/tests/unit/Utils.jsm @@ -121,16 +121,123 @@ define('test/source-map/util', ['require exports.testMapEmptySourceRoot = { version: 3, file: 'min.js', names: ['bar', 'baz', 'n'], sources: ['one.js', 'two.js'], sourceRoot: '', mappings: 'CAAC,IAAI,IAAM,SAAUA,GAClB,OAAOC,IAAID;CCDb,IAAI,IAAM,SAAUE,GAClB,OAAOA' }; + // This mapping is identical to above, but uses the indexed format instead. + exports.indexedTestMap = { + version: 3, + file: 'min.js', + sections: [ + { + offset: { + line: 0, + column: 0 + }, + map: { + version: 3, + sources: [ + "one.js" + ], + sourcesContent: [ + ' ONE.foo = function (bar) {\n' + + ' return baz(bar);\n' + + ' };', + ], + names: [ + "bar", + "baz" + ], + mappings: "CAAC,IAAI,IAAM,SAAUA,GAClB,OAAOC,IAAID", + file: "min.js", + sourceRoot: "/the/root" + } + }, + { + offset: { + line: 1, + column: 0 + }, + map: { + version: 3, + sources: [ + "two.js" + ], + sourcesContent: [ + ' TWO.inc = function (n) {\n' + + ' return n + 1;\n' + + ' };' + ], + names: [ + "n" + ], + mappings: "CAAC,IAAI,IAAM,SAAUA,GAClB,OAAOA", + file: "min.js", + sourceRoot: "/the/root" + } + } + ] + }; + exports.indexedTestMapDifferentSourceRoots = { + version: 3, + file: 'min.js', + sections: [ + { + offset: { + line: 0, + column: 0 + }, + map: { + version: 3, + sources: [ + "one.js" + ], + sourcesContent: [ + ' ONE.foo = function (bar) {\n' + + ' return baz(bar);\n' + + ' };', + ], + names: [ + "bar", + "baz" + ], + mappings: "CAAC,IAAI,IAAM,SAAUA,GAClB,OAAOC,IAAID", + file: "min.js", + sourceRoot: "/the/root" + } + }, + { + offset: { + line: 1, + column: 0 + }, + map: { + version: 3, + sources: [ + "two.js" + ], + sourcesContent: [ + ' TWO.inc = function (n) {\n' + + ' return n + 1;\n' + + ' };' + ], + names: [ + "n" + ], + mappings: "CAAC,IAAI,IAAM,SAAUA,GAClB,OAAOA", + file: "min.js", + sourceRoot: "/different/root" + } + } + ] + }; exports.testMapWithSourcesContent = { version: 3, file: 'min.js', names: ['bar', 'baz', 'n'], sources: ['one.js', 'two.js'], sourcesContent: [ ' ONE.foo = function (bar) {\n' + ' return baz(bar);\n' + @@ -163,22 +270,23 @@ define('test/source-map/util', ['require file: 'min.js', names: [], sources: [], mappings: '' }; function assertMapping(generatedLine, generatedColumn, originalSource, - originalLine, originalColumn, name, map, assert, + originalLine, originalColumn, name, bias, map, assert, dontTestGenerated, dontTestOriginal) { if (!dontTestOriginal) { var origMapping = map.originalPositionFor({ line: generatedLine, - column: generatedColumn + column: generatedColumn, + bias: bias }); assert.equal(origMapping.name, name, 'Incorrect name, expected ' + JSON.stringify(name) + ', got ' + JSON.stringify(origMapping.name)); assert.equal(origMapping.line, originalLine, 'Incorrect line, expected ' + JSON.stringify(originalLine) + ', got ' + JSON.stringify(origMapping.line)); assert.equal(origMapping.column, originalColumn, @@ -201,17 +309,18 @@ define('test/source-map/util', ['require 'Incorrect source, expected ' + JSON.stringify(expectedSource) + ', got ' + JSON.stringify(origMapping.source)); } if (!dontTestGenerated) { var genMapping = map.generatedPositionFor({ source: originalSource, line: originalLine, - column: originalColumn + column: originalColumn, + bias: bias }); assert.equal(genMapping.line, generatedLine, 'Incorrect line, expected ' + JSON.stringify(generatedLine) + ', got ' + JSON.stringify(genMapping.line)); assert.equal(genMapping.column, generatedColumn, 'Incorrect column, expected ' + JSON.stringify(generatedColumn) + ', got ' + JSON.stringify(genMapping.column)); } @@ -516,27 +625,27 @@ define('lib/source-map/util', ['require' return cmp; } cmp = mappingA.originalColumn - mappingB.originalColumn; if (cmp || onlyCompareOriginal) { return cmp; } - cmp = strcmp(mappingA.name, mappingB.name); + cmp = mappingA.generatedColumn - mappingB.generatedColumn; if (cmp) { return cmp; } cmp = mappingA.generatedLine - mappingB.generatedLine; if (cmp) { return cmp; } - return mappingA.generatedColumn - mappingB.generatedColumn; + return strcmp(mappingA.name, mappingB.name); }; exports.compareByOriginalPositions = compareByOriginalPositions; /** * Comparator between two mappings where the generated positions are * compared. * * Optionally pass in `true` as `onlyCompareGenerated` to consider two
--- a/toolkit/devtools/sourcemap/tests/unit/test_base64_vlq.js +++ b/toolkit/devtools/sourcemap/tests/unit/test_base64_vlq.js @@ -14,18 +14,19 @@ Components.utils.import('resource://test */ define("test/source-map/test-base64-vlq", ["require", "exports", "module"], function (require, exports, module) { var base64VLQ = require('source-map/base64-vlq'); exports['test normal encoding and decoding'] = function (assert, util) { var result = {}; for (var i = -255; i < 256; i++) { - base64VLQ.decode(base64VLQ.encode(i), result); + var str = base64VLQ.encode(i); + base64VLQ.decode(str, 0, result); assert.equal(result.value, i); - assert.equal(result.rest, ""); + assert.equal(result.rest, str.length); } }; }); function run_test() { runSourceMapTests('test/source-map/test-base64-vlq', do_throw); }
--- a/toolkit/devtools/sourcemap/tests/unit/test_binary_search.js +++ b/toolkit/devtools/sourcemap/tests/unit/test_binary_search.js @@ -15,48 +15,96 @@ Components.utils.import('resource://test define("test/source-map/test-binary-search", ["require", "exports", "module"], function (require, exports, module) { var binarySearch = require('source-map/binary-search'); function numberCompare(a, b) { return a - b; } - exports['test too high'] = function (assert, util) { + exports['test too high with default (glb) bias'] = function (assert, util) { var needle = 30; var haystack = [2,4,6,8,10,12,14,16,18,20]; assert.doesNotThrow(function () { binarySearch.search(needle, haystack, numberCompare); }); assert.equal(haystack[binarySearch.search(needle, haystack, numberCompare)], 20); }; - exports['test too low'] = function (assert, util) { + exports['test too low with default (glb) bias'] = function (assert, util) { var needle = 1; var haystack = [2,4,6,8,10,12,14,16,18,20]; assert.doesNotThrow(function () { binarySearch.search(needle, haystack, numberCompare); }); assert.equal(binarySearch.search(needle, haystack, numberCompare), -1); }; + exports['test too high with lub bias'] = function (assert, util) { + var needle = 30; + var haystack = [2,4,6,8,10,12,14,16,18,20]; + + assert.doesNotThrow(function () { + binarySearch.search(needle, haystack, numberCompare); + }); + + assert.equal(binarySearch.search(needle, haystack, numberCompare, + binarySearch.LEAST_UPPER_BOUND), -1); + }; + + exports['test too low with lub bias'] = function (assert, util) { + var needle = 1; + var haystack = [2,4,6,8,10,12,14,16,18,20]; + + assert.doesNotThrow(function () { + binarySearch.search(needle, haystack, numberCompare); + }); + + assert.equal(haystack[binarySearch.search(needle, haystack, numberCompare, + binarySearch.LEAST_UPPER_BOUND)], 2); + }; + exports['test exact search'] = function (assert, util) { var needle = 4; var haystack = [2,4,6,8,10,12,14,16,18,20]; assert.equal(haystack[binarySearch.search(needle, haystack, numberCompare)], 4); }; - exports['test fuzzy search'] = function (assert, util) { + exports['test fuzzy search with default (glb) bias'] = function (assert, util) { var needle = 19; var haystack = [2,4,6,8,10,12,14,16,18,20]; assert.equal(haystack[binarySearch.search(needle, haystack, numberCompare)], 18); }; + exports['test fuzzy search with lub bias'] = function (assert, util) { + var needle = 19; + var haystack = [2,4,6,8,10,12,14,16,18,20]; + + assert.equal(haystack[binarySearch.search(needle, haystack, numberCompare, + binarySearch.LEAST_UPPER_BOUND)], 20); + }; + + exports['test multiple matches'] = function (assert, util) { + var needle = 5; + var haystack = [1, 1, 2, 5, 5, 5, 13, 21]; + + assert.equal(binarySearch.search(needle, haystack, numberCompare, + binarySearch.LEAST_UPPER_BOUND), 3); + }; + + exports['test multiple matches at the beginning'] = function (assert, util) { + var needle = 1; + var haystack = [1, 1, 2, 5, 5, 5, 13, 21]; + + assert.equal(binarySearch.search(needle, haystack, numberCompare, + binarySearch.LEAST_UPPER_BOUND), 0); + }; + }); function run_test() { runSourceMapTests('test/source-map/test-binary-search', do_throw); }
--- a/toolkit/devtools/sourcemap/tests/unit/test_dog_fooding.js +++ b/toolkit/devtools/sourcemap/tests/unit/test_dog_fooding.js @@ -51,42 +51,63 @@ define("test/source-map/test-dog-fooding source: 'gza.coffee', original: { line: 5, column: 10 }, generated: { line: 6, column: 12 } }); var smc = new SourceMapConsumer(smg.toString()); // Exact - util.assertMapping(2, 2, '/wu/tang/gza.coffee', 1, 0, null, smc, assert); - util.assertMapping(3, 2, '/wu/tang/gza.coffee', 2, 0, null, smc, assert); - util.assertMapping(4, 2, '/wu/tang/gza.coffee', 3, 0, null, smc, assert); - util.assertMapping(5, 2, '/wu/tang/gza.coffee', 4, 0, null, smc, assert); - util.assertMapping(6, 12, '/wu/tang/gza.coffee', 5, 10, null, smc, assert); + util.assertMapping(2, 2, '/wu/tang/gza.coffee', 1, 0, null, null, smc, assert); + util.assertMapping(3, 2, '/wu/tang/gza.coffee', 2, 0, null, null, smc, assert); + util.assertMapping(4, 2, '/wu/tang/gza.coffee', 3, 0, null, null, smc, assert); + util.assertMapping(5, 2, '/wu/tang/gza.coffee', 4, 0, null, null, smc, assert); + util.assertMapping(6, 12, '/wu/tang/gza.coffee', 5, 10, null, null, smc, assert); // Fuzzy - // Generated to original - util.assertMapping(2, 0, null, null, null, null, smc, assert, true); - util.assertMapping(2, 9, '/wu/tang/gza.coffee', 1, 0, null, smc, assert, true); - util.assertMapping(3, 0, null, null, null, null, smc, assert, true); - util.assertMapping(3, 9, '/wu/tang/gza.coffee', 2, 0, null, smc, assert, true); - util.assertMapping(4, 0, null, null, null, null, smc, assert, true); - util.assertMapping(4, 9, '/wu/tang/gza.coffee', 3, 0, null, smc, assert, true); - util.assertMapping(5, 0, null, null, null, null, smc, assert, true); - util.assertMapping(5, 9, '/wu/tang/gza.coffee', 4, 0, null, smc, assert, true); - util.assertMapping(6, 0, null, null, null, null, smc, assert, true); - util.assertMapping(6, 9, null, null, null, null, smc, assert, true); - util.assertMapping(6, 13, '/wu/tang/gza.coffee', 5, 10, null, smc, assert, true); + // Generated to original with default (glb) bias. + util.assertMapping(2, 0, null, null, null, null, null, smc, assert, true); + util.assertMapping(2, 9, '/wu/tang/gza.coffee', 1, 0, null, null, smc, assert, true); + util.assertMapping(3, 0, null, null, null, null, null, smc, assert, true); + util.assertMapping(3, 9, '/wu/tang/gza.coffee', 2, 0, null, null, smc, assert, true); + util.assertMapping(4, 0, null, null, null, null, null, smc, assert, true); + util.assertMapping(4, 9, '/wu/tang/gza.coffee', 3, 0, null, null, smc, assert, true); + util.assertMapping(5, 0, null, null, null, null, null, smc, assert, true); + util.assertMapping(5, 9, '/wu/tang/gza.coffee', 4, 0, null, null, smc, assert, true); + util.assertMapping(6, 0, null, null, null, null, null, smc, assert, true); + util.assertMapping(6, 9, null, null, null, null, null, smc, assert, true); + util.assertMapping(6, 13, '/wu/tang/gza.coffee', 5, 10, null, null, smc, assert, true); - // Original to generated - util.assertMapping(2, 2, '/wu/tang/gza.coffee', 1, 1, null, smc, assert, null, true); - util.assertMapping(3, 2, '/wu/tang/gza.coffee', 2, 3, null, smc, assert, null, true); - util.assertMapping(4, 2, '/wu/tang/gza.coffee', 3, 6, null, smc, assert, null, true); - util.assertMapping(5, 2, '/wu/tang/gza.coffee', 4, 9, null, smc, assert, null, true); - util.assertMapping(5, 2, '/wu/tang/gza.coffee', 5, 9, null, smc, assert, null, true); - util.assertMapping(6, 12, '/wu/tang/gza.coffee', 6, 19, null, smc, assert, null, true); + // Generated to original with lub bias. + util.assertMapping(2, 0, '/wu/tang/gza.coffee', 1, 0, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, true); + util.assertMapping(2, 9, null, null, null, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, true); + util.assertMapping(3, 0, '/wu/tang/gza.coffee', 2, 0, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, true); + util.assertMapping(3, 9, null, null, null, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, true); + util.assertMapping(4, 0, '/wu/tang/gza.coffee', 3, 0, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, true); + util.assertMapping(4, 9, null, null, null, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, true); + util.assertMapping(5, 0, '/wu/tang/gza.coffee', 4, 0, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, true); + util.assertMapping(5, 9, null, null, null, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, true); + util.assertMapping(6, 0, '/wu/tang/gza.coffee', 5, 10, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, true); + util.assertMapping(6, 9, '/wu/tang/gza.coffee', 5, 10, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, true); + util.assertMapping(6, 13, null, null, null, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, true); + + // Original to generated with default (glb) bias + util.assertMapping(2, 2, '/wu/tang/gza.coffee', 1, 1, null, null, smc, assert, null, true); + util.assertMapping(3, 2, '/wu/tang/gza.coffee', 2, 3, null, null, smc, assert, null, true); + util.assertMapping(4, 2, '/wu/tang/gza.coffee', 3, 6, null, null, smc, assert, null, true); + util.assertMapping(5, 2, '/wu/tang/gza.coffee', 4, 9, null, null, smc, assert, null, true); + util.assertMapping(5, 2, '/wu/tang/gza.coffee', 5, 9, null, null, smc, assert, null, true); + util.assertMapping(6, 12, '/wu/tang/gza.coffee', 6, 19, null, null, smc, assert, null, true); + + // Original to generated with lub bias. + util.assertMapping(3, 2, '/wu/tang/gza.coffee', 1, 1, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, null, true); + util.assertMapping(4, 2, '/wu/tang/gza.coffee', 2, 3, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, null, true); + util.assertMapping(5, 2, '/wu/tang/gza.coffee', 3, 6, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, null, true); + util.assertMapping(6, 12, '/wu/tang/gza.coffee', 4, 9, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, null, true); + util.assertMapping(6, 12, '/wu/tang/gza.coffee', 5, 9, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, null, true); + util.assertMapping(null, null, '/wu/tang/gza.coffee', 6, 19, null, SourceMapConsumer.LEAST_UPPER_BOUND, smc, assert, null, true); }; }); function run_test() { runSourceMapTests('test/source-map/test-dog-fooding', do_throw); }
--- a/toolkit/devtools/sourcemap/tests/unit/test_source_map_consumer.js +++ b/toolkit/devtools/sourcemap/tests/unit/test_source_map_consumer.js @@ -10,37 +10,63 @@ Components.utils.import('resource://test /* * Copyright 2011 Mozilla Foundation and contributors * Licensed under the New BSD license. See LICENSE or: * http://opensource.org/licenses/BSD-3-Clause */ define("test/source-map/test-source-map-consumer", ["require", "exports", "module"], function (require, exports, module) { var SourceMapConsumer = require('source-map/source-map-consumer').SourceMapConsumer; + var IndexedSourceMapConsumer = require('source-map/source-map-consumer').IndexedSourceMapConsumer; + var BasicSourceMapConsumer = require('source-map/source-map-consumer').BasicSourceMapConsumer; var SourceMapGenerator = require('source-map/source-map-generator').SourceMapGenerator; exports['test that we can instantiate with a string or an object'] = function (assert, util) { assert.doesNotThrow(function () { var map = new SourceMapConsumer(util.testMap); }); assert.doesNotThrow(function () { var map = new SourceMapConsumer(JSON.stringify(util.testMap)); }); }; + exports['test that the object returned from new SourceMapConsumer inherits from SourceMapConsumer'] = function (assert, util) { + assert.ok(new SourceMapConsumer(util.testMap) instanceof SourceMapConsumer); + } + + exports['test that a BasicSourceMapConsumer is returned for sourcemaps without sections'] = function(assert, util) { + assert.ok(new SourceMapConsumer(util.testMap) instanceof BasicSourceMapConsumer); + }; + + exports['test that an IndexedSourceMapConsumer is returned for sourcemaps with sections'] = function(assert, util) { + assert.ok(new SourceMapConsumer(util.indexedTestMap) instanceof IndexedSourceMapConsumer); + }; + exports['test that the `sources` field has the original sources'] = function (assert, util) { var map; var sources; map = new SourceMapConsumer(util.testMap); sources = map.sources; assert.equal(sources[0], '/the/root/one.js'); assert.equal(sources[1], '/the/root/two.js'); assert.equal(sources.length, 2); + map = new SourceMapConsumer(util.indexedTestMap); + sources = map.sources; + assert.equal(sources[0], '/the/root/one.js'); + assert.equal(sources[1], '/the/root/two.js'); + assert.equal(sources.length, 2); + + map = new SourceMapConsumer(util.indexedTestMapDifferentSourceRoots); + sources = map.sources; + assert.equal(sources[0], '/the/root/one.js'); + assert.equal(sources[1], '/different/root/two.js'); + assert.equal(sources.length, 2); + map = new SourceMapConsumer(util.testMapNoSourceRoot); sources = map.sources; assert.equal(sources[0], 'one.js'); assert.equal(sources[1], 'two.js'); assert.equal(sources.length, 2); map = new SourceMapConsumer(util.testMapEmptySourceRoot); sources = map.sources; @@ -96,68 +122,149 @@ define("test/source-map/test-source-map- column: 1 }); assert.equal(mapping.source, 'one.js'); }; exports['test mapping tokens back exactly'] = function (assert, util) { var map = new SourceMapConsumer(util.testMap); - util.assertMapping(1, 1, '/the/root/one.js', 1, 1, null, map, assert); - util.assertMapping(1, 5, '/the/root/one.js', 1, 5, null, map, assert); - util.assertMapping(1, 9, '/the/root/one.js', 1, 11, null, map, assert); - util.assertMapping(1, 18, '/the/root/one.js', 1, 21, 'bar', map, assert); - util.assertMapping(1, 21, '/the/root/one.js', 2, 3, null, map, assert); - util.assertMapping(1, 28, '/the/root/one.js', 2, 10, 'baz', map, assert); - util.assertMapping(1, 32, '/the/root/one.js', 2, 14, 'bar', map, assert); + util.assertMapping(1, 1, '/the/root/one.js', 1, 1, null, null, map, assert); + util.assertMapping(1, 5, '/the/root/one.js', 1, 5, null, null, map, assert); + util.assertMapping(1, 9, '/the/root/one.js', 1, 11, null, null, map, assert); + util.assertMapping(1, 18, '/the/root/one.js', 1, 21, 'bar', null, map, assert); + util.assertMapping(1, 21, '/the/root/one.js', 2, 3, null, null, map, assert); + util.assertMapping(1, 28, '/the/root/one.js', 2, 10, 'baz', null, map, assert); + util.assertMapping(1, 32, '/the/root/one.js', 2, 14, 'bar', null, map, assert); + + util.assertMapping(2, 1, '/the/root/two.js', 1, 1, null, null, map, assert); + util.assertMapping(2, 5, '/the/root/two.js', 1, 5, null, null, map, assert); + util.assertMapping(2, 9, '/the/root/two.js', 1, 11, null, null, map, assert); + util.assertMapping(2, 18, '/the/root/two.js', 1, 21, 'n', null, map, assert); + util.assertMapping(2, 21, '/the/root/two.js', 2, 3, null, null, map, assert); + util.assertMapping(2, 28, '/the/root/two.js', 2, 10, 'n', null, map, assert); + }; + + exports['test mapping tokens back exactly in indexed source map'] = function (assert, util) { + var map = new SourceMapConsumer(util.indexedTestMap); + + util.assertMapping(1, 1, '/the/root/one.js', 1, 1, null, null, map, assert); + util.assertMapping(1, 5, '/the/root/one.js', 1, 5, null, null, map, assert); + util.assertMapping(1, 9, '/the/root/one.js', 1, 11, null, null, map, assert); + util.assertMapping(1, 18, '/the/root/one.js', 1, 21, 'bar', null, map, assert); + util.assertMapping(1, 21, '/the/root/one.js', 2, 3, null, null, map, assert); + util.assertMapping(1, 28, '/the/root/one.js', 2, 10, 'baz', null, map, assert); + util.assertMapping(1, 32, '/the/root/one.js', 2, 14, 'bar', null, map, assert); - util.assertMapping(2, 1, '/the/root/two.js', 1, 1, null, map, assert); - util.assertMapping(2, 5, '/the/root/two.js', 1, 5, null, map, assert); - util.assertMapping(2, 9, '/the/root/two.js', 1, 11, null, map, assert); - util.assertMapping(2, 18, '/the/root/two.js', 1, 21, 'n', map, assert); - util.assertMapping(2, 21, '/the/root/two.js', 2, 3, null, map, assert); - util.assertMapping(2, 28, '/the/root/two.js', 2, 10, 'n', map, assert); + util.assertMapping(2, 1, '/the/root/two.js', 1, 1, null, null, map, assert); + util.assertMapping(2, 5, '/the/root/two.js', 1, 5, null, null, map, assert); + util.assertMapping(2, 9, '/the/root/two.js', 1, 11, null, null, map, assert); + util.assertMapping(2, 18, '/the/root/two.js', 1, 21, 'n', null, map, assert); + util.assertMapping(2, 21, '/the/root/two.js', 2, 3, null, null, map, assert); + util.assertMapping(2, 28, '/the/root/two.js', 2, 10, 'n', null, map, assert); + }; + + + exports['test mapping tokens back exactly'] = function (assert, util) { + var map = new SourceMapConsumer(util.testMap); + + util.assertMapping(1, 1, '/the/root/one.js', 1, 1, null, null, map, assert); + util.assertMapping(1, 5, '/the/root/one.js', 1, 5, null, null, map, assert); + util.assertMapping(1, 9, '/the/root/one.js', 1, 11, null, null, map, assert); + util.assertMapping(1, 18, '/the/root/one.js', 1, 21, 'bar', null, map, assert); + util.assertMapping(1, 21, '/the/root/one.js', 2, 3, null, null, map, assert); + util.assertMapping(1, 28, '/the/root/one.js', 2, 10, 'baz', null, map, assert); + util.assertMapping(1, 32, '/the/root/one.js', 2, 14, 'bar', null, map, assert); + + util.assertMapping(2, 1, '/the/root/two.js', 1, 1, null, null, map, assert); + util.assertMapping(2, 5, '/the/root/two.js', 1, 5, null, null, map, assert); + util.assertMapping(2, 9, '/the/root/two.js', 1, 11, null, null, map, assert); + util.assertMapping(2, 18, '/the/root/two.js', 1, 21, 'n', null, map, assert); + util.assertMapping(2, 21, '/the/root/two.js', 2, 3, null, null, map, assert); + util.assertMapping(2, 28, '/the/root/two.js', 2, 10, 'n', null, map, assert); }; exports['test mapping tokens fuzzy'] = function (assert, util) { var map = new SourceMapConsumer(util.testMap); - // Finding original positions - util.assertMapping(1, 20, '/the/root/one.js', 1, 21, 'bar', map, assert, true); - util.assertMapping(1, 30, '/the/root/one.js', 2, 10, 'baz', map, assert, true); - util.assertMapping(2, 12, '/the/root/two.js', 1, 11, null, map, assert, true); + // Finding original positions with default (glb) bias. + util.assertMapping(1, 20, '/the/root/one.js', 1, 21, 'bar', null, map, assert, true); + util.assertMapping(1, 30, '/the/root/one.js', 2, 10, 'baz', null, map, assert, true); + util.assertMapping(2, 12, '/the/root/two.js', 1, 11, null, null, map, assert, true); + + // Finding original positions with lub bias. + util.assertMapping(1, 16, '/the/root/one.js', 1, 21, 'bar', SourceMapConsumer.LEAST_UPPER_BOUND, map, assert, true); + util.assertMapping(1, 26, '/the/root/one.js', 2, 10, 'baz', SourceMapConsumer.LEAST_UPPER_BOUND, map, assert, true); + util.assertMapping(2, 6, '/the/root/two.js', 1, 11, null, SourceMapConsumer.LEAST_UPPER_BOUND, map, assert, true); + + // Finding generated positions with default (glb) bias. + util.assertMapping(1, 18, '/the/root/one.js', 1, 22, 'bar', null, map, assert, null, true); + util.assertMapping(1, 28, '/the/root/one.js', 2, 13, 'baz', null, map, assert, null, true); + util.assertMapping(2, 9, '/the/root/two.js', 1, 16, null, null, map, assert, null, true); + + // Finding generated positions with lub bias. + util.assertMapping(1, 18, '/the/root/one.js', 1, 20, 'bar', SourceMapConsumer.LEAST_UPPER_BOUND, map, assert, null, true); + util.assertMapping(1, 28, '/the/root/one.js', 2, 7, 'baz', SourceMapConsumer.LEAST_UPPER_BOUND, map, assert, null, true); + util.assertMapping(2, 9, '/the/root/two.js', 1, 6, null, SourceMapConsumer.LEAST_UPPER_BOUND, map, assert, null, true); + }; - // Finding generated positions - util.assertMapping(1, 18, '/the/root/one.js', 1, 22, 'bar', map, assert, null, true); - util.assertMapping(1, 28, '/the/root/one.js', 2, 13, 'baz', map, assert, null, true); - util.assertMapping(2, 9, '/the/root/two.js', 1, 16, null, map, assert, null, true); + exports['test mapping tokens fuzzy in indexed source map'] = function (assert, util) { + var map = new SourceMapConsumer(util.indexedTestMap); + + // Finding original positions with default (glb) bias. + util.assertMapping(1, 20, '/the/root/one.js', 1, 21, 'bar', null, map, assert, true); + util.assertMapping(1, 30, '/the/root/one.js', 2, 10, 'baz', null, map, assert, true); + util.assertMapping(2, 12, '/the/root/two.js', 1, 11, null, null, map, assert, true); + + // Finding original positions with lub bias. + util.assertMapping(1, 16, '/the/root/one.js', 1, 21, 'bar', SourceMapConsumer.LEAST_UPPER_BOUND, map, assert, true); + util.assertMapping(1, 26, '/the/root/one.js', 2, 10, 'baz', SourceMapConsumer.LEAST_UPPER_BOUND, map, assert, true); + util.assertMapping(2, 6, '/the/root/two.js', 1, 11, null, SourceMapConsumer.LEAST_UPPER_BOUND, map, assert, true); + + // Finding generated positions with default (glb) bias. + util.assertMapping(1, 18, '/the/root/one.js', 1, 22, 'bar', null, map, assert, null, true); + util.assertMapping(1, 28, '/the/root/one.js', 2, 13, 'baz', null, map, assert, null, true); + util.assertMapping(2, 9, '/the/root/two.js', 1, 16, null, null, map, assert, null, true); + + // Finding generated positions with lub bias. + util.assertMapping(1, 18, '/the/root/one.js', 1, 20, 'bar', SourceMapConsumer.LEAST_UPPER_BOUND, map, assert, null, true); + util.assertMapping(1, 28, '/the/root/one.js', 2, 7, 'baz', SourceMapConsumer.LEAST_UPPER_BOUND, map, assert, null, true); + util.assertMapping(2, 9, '/the/root/two.js', 1, 6, null, SourceMapConsumer.LEAST_UPPER_BOUND, map, assert, null, true); }; exports['test mappings and end of lines'] = function (assert, util) { var smg = new SourceMapGenerator({ file: 'foo.js' }); smg.addMapping({ original: { line: 1, column: 1 }, generated: { line: 1, column: 1 }, source: 'bar.js' }); smg.addMapping({ original: { line: 2, column: 2 }, generated: { line: 2, column: 2 }, source: 'bar.js' }); + smg.addMapping({ + original: { line: 1, column: 1 }, + generated: { line: 1, column: 1 }, + source: 'baz.js' + }); var map = SourceMapConsumer.fromSourceMap(smg); // When finding original positions, mappings end at the end of the line. - util.assertMapping(2, 1, null, null, null, null, map, assert, true) + util.assertMapping(2, 1, null, null, null, null, null, map, assert, true) // When finding generated positions, mappings do not end at the end of the line. - util.assertMapping(1, 1, 'bar.js', 2, 1, null, map, assert, null, true); + util.assertMapping(1, 1, 'bar.js', 2, 1, null, null, map, assert, null, true); + + // When finding generated positions with, mappings end at the end of the source. + util.assertMapping(null, null, 'bar.js', 3, 1, null, SourceMapConsumer.LEAST_UPPER_BOUND, map, assert, null, true); }; exports['test creating source map consumers with )]}\' prefix'] = function (assert, util) { assert.doesNotThrow(function () { var map = new SourceMapConsumer(")]}'" + JSON.stringify(util.testMap)); }); }; @@ -188,16 +295,39 @@ define("test/source-map/test-source-map- }); map = new SourceMapConsumer(util.testMapEmptySourceRoot); map.eachMapping(function (mapping) { assert.ok(mapping.source === 'one.js' || mapping.source === 'two.js'); }); }; + exports['test eachMapping for indexed source maps'] = function(assert, util) { + var map = new SourceMapConsumer(util.indexedTestMap); + var previousLine = -Infinity; + var previousColumn = -Infinity; + map.eachMapping(function (mapping) { + assert.ok(mapping.generatedLine >= previousLine); + + if (mapping.source) { + assert.equal(mapping.source.indexOf(util.testMap.sourceRoot), 0); + } + + if (mapping.generatedLine === previousLine) { + assert.ok(mapping.generatedColumn >= previousColumn); + previousColumn = mapping.generatedColumn; + } + else { + previousLine = mapping.generatedLine; + previousColumn = -Infinity; + } + }); + }; + + exports['test iterating over mappings in a different order'] = function (assert, util) { var map = new SourceMapConsumer(util.testMap); var previousLine = -Infinity; var previousColumn = -Infinity; var previousSource = ""; map.eachMapping(function (mapping) { assert.ok(mapping.source >= previousSource); @@ -216,24 +346,60 @@ define("test/source-map/test-source-map- else { previousSource = mapping.source; previousLine = -Infinity; previousColumn = -Infinity; } }, null, SourceMapConsumer.ORIGINAL_ORDER); }; + exports['test iterating over mappings in a different order in indexed source maps'] = function (assert, util) { + var map = new SourceMapConsumer(util.indexedTestMap); + var previousLine = -Infinity; + var previousColumn = -Infinity; + var previousSource = ""; + map.eachMapping(function (mapping) { + assert.ok(mapping.source >= previousSource); + + if (mapping.source === previousSource) { + assert.ok(mapping.originalLine >= previousLine); + + if (mapping.originalLine === previousLine) { + assert.ok(mapping.originalColumn >= previousColumn); + previousColumn = mapping.originalColumn; + } + else { + previousLine = mapping.originalLine; + previousColumn = -Infinity; + } + } + else { + previousSource = mapping.source; + previousLine = -Infinity; + previousColumn = -Infinity; + } + }, null, SourceMapConsumer.ORIGINAL_ORDER); + }; + exports['test that we can set the context for `this` in eachMapping'] = function (assert, util) { var map = new SourceMapConsumer(util.testMap); var context = {}; map.eachMapping(function () { assert.equal(this, context); }, context); }; + exports['test that we can set the context for `this` in eachMapping in indexed source maps'] = function (assert, util) { + var map = new SourceMapConsumer(util.indexedTestMap); + var context = {}; + map.eachMapping(function () { + assert.equal(this, context); + }, context); + }; + exports['test that the `sourcesContent` field has the original sources'] = function (assert, util) { var map = new SourceMapConsumer(util.testMapWithSourcesContent); var sourcesContent = map.sourcesContent;