diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..77f0c855 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,22 @@ +language: node_js +node_js: + - "0.8" +install: + - "bin/installDeps.sh" + - "export GIT_HASH=$(cat .git/HEAD | head -c 7)" +before_script: + - "tests/frontend/travis/sauce_tunnel.sh" +script: + - "tests/frontend/travis/runner.sh" +env: + global: + - secure: "OxZ2s724S96xu02746LUN+4lBckAe1BOICJjfA4jnFPNpiNU6XoMH52f+LgG\nZzAwu6xMTv+NsaLGp6Avm3cx4GZ+jIiHe4NB9XOgYPa0r0TBIi3ueWYPDyVv\nCniS/4qX68DoFNV4lh7zMBXn0IIPxT4Wppm3desBpjWDP/SdoRs=" + - SAUCE_USER=pita +jdk: + - oraclejdk6 +notifications: + email: + - petermartischka@googlemail.com + irc: + channels: + - "irc.freenode.org#etherpad-lite-dev" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5810ed25..abcf0a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +# v1.2 + * Internationalization / Language / Translation support (i18n) with support for German/French + * A frontend/client side testing framework and backend build tests + * Customizable robots.txt + * Customizable app title (finally you can name your epl instance!) + * eejs render arguments are now passed on to eejs hooks through the newly introduced `renderContext` argument. + * Plugin-specific settings in settings.json (finally allowing for things like a google analytics plugin) + * Serve admin dashboard at /admin (still very limited, though) + * Modify your settings.json through the newly created UI at /admin/settings + * Fix: Import <ol>'s as <ol>'s and not as <ul>'s! + * Added solaris compatibility (bin/installDeps.sh was broken on solaris) + * Fix a bug with IE9 and Password Protected Pads using HTTPS + +# v1.1.5 + * We updated to express v3 (please [make sure](https://github.com/visionmedia/express/wiki/Migrating-from-2.x-to-3.x) your plugin works under express v3) + * `userColor` URL parameter which sets the initial author color + * Hooks for "padCreate", "padRemove", "padUpdate" and "padLoad" events + * Security patches concerning the handling of messages originating from clients + * Our database abstraction layer now natively supports couchDB, levelDB, mongoDB, postgres, and redis! + * We now provide a script helping you to migrate from dirtyDB to MySQL + * Support running Etherpad Lite behind IIS, using [iisnode](https://github.com/tjanczuk/iisnode/wiki) + * LibreJS Licensing information in headers of HTML templates + * Default port number to PORT env var, if port isn't specified in settings + * Fix for `convert.js` + * Raise upper char limit in chat to 999 characters + * Fixes for mobile layout + * Fixes for usage behind reverse proxy + * Improved documentation + * Fixed some opera style bugs + * Update npm and fix some bugs, this introduces + # v1.1 * Introduced Plugin framework * Many bugfixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b6cb4d3..b0fff543 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,5 @@ # Developer Guidelines -(Please talk to people on the mailing list before you change this page, see our section on [how to get in touch](https://github.com/Pita/etherpad-lite#get-in-touch)) +(Please talk to people on the mailing list before you change this page, see our section on [how to get in touch](https://github.com/ether/etherpad-lite#get-in-touch)) **Our goal is to iterate in small steps. Release often, release early. Evolution instead of a revolution** diff --git a/README.md b/README.md deleted file mode 100644 index 5f19dab9..00000000 --- a/README.md +++ /dev/null @@ -1,133 +0,0 @@ -<<<<<<< HEAD -# Making collaborative editing the standard on the web - -# About -Etherpad lite is a really-real time collaborative editor spawned from the Hell fire of Etherpad. -We're reusing the well tested Etherpad easysync library to make it really realtime. Etherpad Lite -is based on node.js ergo is much lighter and more stable than the original Etherpad. Our hope -is that this will encourage more users to use and install a realtime collaborative editor. A smaller, manageable and well -documented codebase makes it easier for developers to improve the code and contribute towards the project. - -**Etherpad vs Etherpad lite** -<table> - <tr> - <td> </td><td><b>Etherpad</b></td><td><b>Etherpad Lite</b></td> - </tr> - <tr> - <td align="right">Size of the folder (without git history)</td><td>30 MB</td><td>1.5 MB</td> - </tr> - <tr> - <td align="right">Languages used server side</td><td>Javascript (Rhino), Java, Scala</td><td>Javascript (node.js)</td> - </tr> - <tr> - <td align="right">Lines of server side Javascript code</td><td>~101k</td><td>~9k</td> - </tr> - <tr> - <td align="right">RAM Usage immediately after start</td><td>257 MB (grows to ~1GB)</td><td>16 MB (grows to ~30MB)</td> - </tr> -</table> - - -Etherpad Lite is designed to be easily embeddable and provides a [HTTP API](https://github.com/Pita/etherpad-lite/wiki/HTTP-API) -that allows your web application to manage pads, users and groups. It is recommended to use the client implementations available for this API, listed on [this wiki page](https://github.com/Pita/etherpad-lite/wiki/HTTP-API-client-libraries). -There is also a [jQuery plugin](https://github.com/johnyma22/etherpad-lite-jquery-plugin) that helps you to embed Pads into your website - -**Visit [beta.etherpad.org](http://beta.etherpad.org) to test it live** - -Also, check out the **[FAQ](https://github.com/Pita/etherpad-lite/wiki/FAQ)**, really! - -# Installation - -## Windows - -### Prebuilt windows package -This package works out of the box on any windows machine, but it's not very useful for developing purposes... - -1. Download the windows package <https://github.com/Pita/etherpad-lite/downloads> -2. Extract the folder - -Now, run `start.bat` and open <http://localhost:9001> in your browser. You like it? [Next steps](#next-steps). - -### Fancy install -You'll need [node.js](http://nodejs.org) and (optionally, though recommended) git. - -1. Grab the source, either - - download <https://github.com/Pita/etherpad-lite/zipball/master> - - or `git clone https://github.com/Pita/etherpad-lite.git` (for this you need git, obviously) -2. start `bin\installOnWindows.bat` - -Now, run `start.bat` and open <http://localhost:9001> in your browser. - -Update to the latest version with `git pull origin`, then run `bin\installOnWindows.bat`, again. - -[Next steps](#next-steps). - -## Linux -You'll need gzip, git, curl, libssl develop libraries, python and gcc. -*For Debian/Ubuntu*: `apt-get install gzip git-core curl python libssl-dev pkg-config build-essential` -*For Fedora/CentOS*: `yum install gzip git-core curl python openssl-devel && yum groupinstall "Development Tools"` - -Additionally, you'll need [node.js](http://nodejs.org). - -**As any user (we recommend creating a separate user called etherpad-lite):** - -1. Move to a folder where you want to install Etherpad Lite. Clone the git repository `git clone git://github.com/Pita/etherpad-lite.git` -2. Change into the new directory containing the cloned source code `cd etherpad-lite` - -Now, run `bin/run.sh` and open <http://127.0.0.1:9001> in your browser. - -Update to the latest version with `git pull origin`. The next start with bin/run.sh will update the dependencies. - -You like it? [Next steps](#next-steps). - -# Next Steps - -## Tweak the settings -You can modify the settings in `settings.json`. (If you need to handle multiple settings files, you can pass the path to a settings file to `bin/run.sh` using the `-s|--settings` option. This allows you to run multiple Etherpad Lite instances from the same installation.) - -You should use a dedicated database such as "mysql", if you are planning on using etherpad-lite in a production environment, since the "dirtyDB" database driver is only for testing and/or development purposes. - -## Helpful resources -The [wiki](https://github.com/Pita/etherpad-lite/wiki) is your one-stop resource for Tutorials and How-to's, really check it out! Also, feel free to improve these wiki pages. - -Documentation can be found in `docs/`. - -# Development - -## Things you should know -Read this [git guide](http://learn.github.com/p/intro.html) and watch this [video on getting started with Etherpad Lite Development](http://youtu.be/67-Q26YH97E). - -If you're new to node.js, start with Ryan Dahl's [Introduction to Node.js](http://youtu.be/jo_B4LTHi3I). - -You can debug Etherpad lite using `bin/debugRun.sh`. - -If you want to find out how Etherpad's `Easysync` works (the library that makes it really realtime), start with this [PDF](https://github.com/Pita/etherpad-lite/raw/master/doc/easysync/easysync-full-description.pdf) (complex, but worth reading). - -## Getting started -You know all this and just want to know how you can help? - -Look at the [TODO list](https://github.com/Pita/etherpad-lite/wiki/TODO) and our [Issue tracker](https://github.com/Pita/etherpad-lite/issues). (Please consider using [jshint](http://www.jshint.com/about/), if you plan to contribute code.) - -Also, and most importantly, read our [**Developer Guidelines**](https://github.com/Pita/etherpad-lite/wiki/Developer-Guidelines), really! - -# Get in touch -Join the [mailinglist](http://groups.google.com/group/etherpad-lite-dev) and make some noise on our freenode irc channel [#etherpad-lite-dev](http://webchat.freenode.net?channels=#etherpad-lite-dev)! - -# Modules created for this project - -* [ueberDB](https://github.com/Pita/ueberDB) "transforms every database into a object key value store" - manages all database access -* [channels](https://github.com/Pita/channels) "Event channels in node.js" - ensures that ueberDB operations are atomic and in series for each key -* [async-stacktrace](https://github.com/Pita/async-stacktrace) "Improves node.js stacktraces and makes it easier to handle errors" - -# Donate! -* [Flattr] (http://flattr.com/thing/71378/Etherpad-Foundation) -* Paypal - Press the donate button on [etherpad.org](http://etherpad.org) - -# License -[Apache License v2](http://www.apache.org/licenses/LICENSE-2.0.html) -======= -We moved! -============= - -You can now find Etherpad Lite under [github.com/ether/etherpad-lite](https://github.com/ether/etherpad-lite) ->>>>>>> a5c4fb154f7414cd5dca9e50c3f2ad00e42672f5 diff --git a/bin/buildForWindows.sh b/bin/buildForWindows.sh index 1d47bff1..c67a3701 100755 --- a/bin/buildForWindows.sh +++ b/bin/buildForWindows.sh @@ -52,6 +52,13 @@ echo "download windows node..." cd bin wget "http://nodejs.org/dist/v$NODE_VERSION/node.exe" -O ../node.exe +echo "remove git history to reduce folder size" +rm -rf .git/objects + +echo "remove windows jsdom-nocontextify/test folder" +rm -rf /tmp/etherpad-lite-win/node_modules/ep_etherpad-lite/node_modules/jsdom-nocontextifiy/test/ +rm -rf /tmp/etherpad-lite-win/src/node_modules/jsdom-nocontextifiy/test/ + echo "create the zip..." cd /tmp zip -9 -r etherpad-lite-win.zip etherpad-lite-win diff --git a/bin/installDeps.sh b/bin/installDeps.sh index 15731ae9..6f5c732c 100755 --- a/bin/installDeps.sh +++ b/bin/installDeps.sh @@ -69,7 +69,7 @@ echo "Ensure that all dependencies are up to date..." cd node_modules [ -e ep_etherpad-lite ] || ln -s ../src ep_etherpad-lite cd ep_etherpad-lite - npm install + npm install --loglevel warn ) || { rm -rf node_modules exit 1 @@ -79,7 +79,7 @@ echo "Ensure jQuery is downloaded and up to date..." DOWNLOAD_JQUERY="true" NEEDED_VERSION="1.7.1" if [ -f "src/static/js/jquery.js" ]; then - if [ $(uname) = "SunOS"]; then + if [ $(uname) = "SunOS" ]; then VERSION=$(cat src/static/js/jquery.js | head -n 3 | ggrep -o "v[0-9]\.[0-9]\(\.[0-9]\)\?"); else VERSION=$(cat src/static/js/jquery.js | head -n 3 | grep -o "v[0-9]\.[0-9]\(\.[0-9]\)\?"); diff --git a/bin/installOnWindows.bat b/bin/installOnWindows.bat index b4e4f540..32ff847f 100644 --- a/bin/installOnWindows.bat +++ b/bin/installOnWindows.bat @@ -13,7 +13,7 @@ cmd /C node -e %check_version% || exit /B 1 echo _ echo Installing etherpad-lite and dependencies... -cmd /C npm install src/ || exit /B 1 +cmd /C npm install src/ --loglevel warn || exit /B 1 echo _ echo Copying custom templates... diff --git a/bin/migrateDirtyDBtoMySQL.js b/bin/migrateDirtyDBtoMySQL.js index f2bc8efe..d0273de0 100644 --- a/bin/migrateDirtyDBtoMySQL.js +++ b/bin/migrateDirtyDBtoMySQL.js @@ -1,11 +1,17 @@ -var dirty = require("../src/node_modules/ueberDB/node_modules/dirty")('var/dirty.db'); -var db = require("../src/node/db/DB"); +require("ep_etherpad-lite/node_modules/npm").load({}, function(er,npm) { -db.init(function() { - db = db.db; - dirty.on("load", function() { - dirty.forEach(function(key, value) { - db.set(key, value); + process.chdir(npm.root+'/..') + + var dirty = require("ep_etherpad-lite/node_modules/ueberDB/node_modules/dirty")('var/dirty.db'); + var db = require("ep_etherpad-lite/node/db/DB"); + + db.init(function() { + db = db.db; + dirty.on("load", function() { + dirty.forEach(function(key, value) { + db.set(key, value); + }); }); }); + }); diff --git a/doc/api/embed_parameters.md b/doc/api/embed_parameters.md index 7f84f064..3100fff9 100644 --- a/doc/api/embed_parameters.md +++ b/doc/api/embed_parameters.md @@ -52,3 +52,11 @@ Default: false * Boolean Default: false + +## lang + * String + +Default: en + +Example: `lang=ar` (translates the interface into Arabic) + diff --git a/doc/index.md b/doc/index.md index db7cefaa..5d3022be 100644 --- a/doc/index.md +++ b/doc/index.md @@ -1,5 +1,6 @@ @include documentation -@include cusotm_static +@include localization +@include custom_static @include api/api @include plugins @include database diff --git a/doc/localization.md b/doc/localization.md new file mode 100644 index 00000000..3f0901ca --- /dev/null +++ b/doc/localization.md @@ -0,0 +1,19 @@ +# Localization +Etherpad lite provides a multi-language user interface, that's apart from your users' content, so users from different countries can collaborate on a single document, while still having the user interface displayed in their mother tongue. + +## Translating +`/src/locales` contains files for all supported languages which contain the translated strings. To add support for a new language, copy the English language file named `en.ini` and translate it. +Translation files are simply `*.ini` files and look like this: + +``` +pad.modals.connected = Connect�. +pad.modals.uderdup = Ouvrir dans une nouvelle fen�tre. +pad.toolbar.unindent.title = D�sindenter +pad.toolbar.undo.title = Annuler (Ctrl-Z) +timeslider.pageTitle = {{appTitle}} Curseur temporel +``` + +There must be only one translation per line. Each translation consists of a key (the id of the string that is to be translated), an equal sign and the translated string. Anything after the equa sign will be used as the translated string (you may put some spaces after `=` for better readability, though). Terms in curly braces must not be touched but left as they are, since they represent a dynamically changing part of the string like a variable. Imagine a message welcoming a user: `Welcome, {{userName}}!` would be translated as `Ahoy, {{userName}}!` in pirate. + +## Under the hood +We use a `language` cookie to save your language settings if you change them. If you don't, we autodetect your locale using information from your browser. Now, that we know your preferred language this information is feeded into a very nice library called [webL10n](https://github.com/fabi1cazenave/webL10n), which loads the appropriate translations and applies them to our templates, providing translation params, pluralization, include rules and even a nice javascript API along the way. \ No newline at end of file diff --git a/src/ep.json b/src/ep.json index 26e4f603..89c8964a 100644 --- a/src/ep.json +++ b/src/ep.json @@ -5,6 +5,7 @@ "restartServer": "ep_etherpad-lite/node/hooks/express:restartServer" } }, { "name": "static", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/static:expressCreateServer" } }, + { "name": "i18n", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/i18n:expressCreateServer" } }, { "name": "specialpages", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/specialpages:expressCreateServer" } }, { "name": "padurlsanitize", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/padurlsanitize:expressCreateServer" } }, { "name": "padreadonly", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/padreadonly:expressCreateServer" } }, @@ -13,6 +14,7 @@ { "name": "importexport", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/importexport:expressCreateServer" } }, { "name": "errorhandling", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/errorhandling:expressCreateServer" } }, { "name": "socketio", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/socketio:expressCreateServer" } }, + { "name": "tests", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/tests:expressCreateServer" } }, { "name": "admin", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/admin:expressCreateServer" } }, { "name": "adminplugins", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/adminplugins:expressCreateServer", diff --git a/src/locales/bn.ini b/src/locales/bn.ini new file mode 100644 index 00000000..7eca9e38 --- /dev/null +++ b/src/locales/bn.ini @@ -0,0 +1,32 @@ +; Exported from translatewiki.net +; Author: Nasir8891 +[bn] +index.newPad = নতুন প্যাড +index.createOpenPad = অথবা নাম লিখে প্যাড খুলুন/তৈরী করুন: +pad.toolbar.bold.title = গাড় করা (Ctrl-B) +pad.toolbar.italic.title = বাঁকা করা (Ctrl-I) +pad.toolbar.settings.title = সেটিং +pad.colorpicker.save = সংরক্ষণ +pad.colorpicker.cancel = বাতিল +pad.loading = লোডিং... +pad.settings.language = ভাষা: +pad.importExport.successful = সফল! +; Fuzzy +pad.importExport.export = এই প্যাডটি এক্সপোর্ট করুন +pad.importExport.exporthtml = এইচটিএমএল +pad.importExport.exportplain = সাধারণ লেখা +pad.importExport.exportword = মাইক্রোসফট ওয়ার্ড +pad.importExport.exportpdf = পিডিএফ +pad.importExport.exportopen = ওডিএফ (ওপেন ডকুমেন্ট ফরম্যাট) +pad.modals.deleted = অপসারিত। +pad.modals.deleted.explanation = এই প্যাডটি অপসারণ করা হয়েছে। +pad.modals.disconnected.explanation = সার্ভারের সাথে যোগাযোগ করা যাচ্ছে না +pad.share = শেয়ার করুন +pad.share.link = লিংক +pad.share.emebdcode = ইউআরএল সংযোজন +pad.chat = চ্যাট +pad.chat.title = এই প্যাডের জন্য চ্যাট চালু করুন। +timeslider.toolbar.returnbutton = প্যাডে ফিরে যাও +timeslider.toolbar.authors = লেখকগণ: +timeslider.toolbar.authorsList = কোনো লেখক নেই +timeslider.exportCurrent = বর্তমান সংস্করণটি এক্সপোর্ট করুন: diff --git a/src/locales/de.ini b/src/locales/de.ini new file mode 100644 index 00000000..177746a5 --- /dev/null +++ b/src/locales/de.ini @@ -0,0 +1,78 @@ +; Exported from translatewiki.net +[de] +index.newPad = Neues Pad +index.createOpenPad = Pad mit folgendem Namen öffnen +pad.toolbar.bold.title = Fett (Strg-B) +pad.toolbar.italic.title = Kursiv (Strg-I) +pad.toolbar.underline.title = Unterstrichen (Strg-U) +pad.toolbar.strikethrough.title = Durchgestrichen +pad.toolbar.ol.title = Nummerierte Liste +pad.toolbar.ul.title = Ungeordnete Liste +pad.toolbar.indent.title = Einrücken +pad.toolbar.unindent.title = Ausrücken +pad.toolbar.undo.title = Rückgängig (Strg-Z) +pad.toolbar.redo.title = Wiederholen (Strg-Y) +pad.toolbar.clearAuthorship.title = Autorenfarben zurücksetzen +pad.toolbar.import_export.title = Import/Export von verschiedenen Dateiformaten +pad.toolbar.timeslider.title = Pad-Geschichte anzeigen +pad.toolbar.savedRevision.title = Diese Revision markieren +pad.toolbar.settings.title = Einstellungen +pad.toolbar.embed.title = Dieses Pad teilen oder einbetten +pad.toolbar.showusers.title = Verbundene Benutzer anzeigen +pad.colorpicker.save = Speichern +pad.colorpicker.cancel = Abbrechen +pad.loading = Laden... +pad.settings.padSettings = Pad Einstellungen +pad.settings.myView = Eigene Ansicht +pad.settings.stickychat = Chat immer anzeigen +pad.settings.colorcheck = Autorenfarben anzeigen +pad.settings.linenocheck = Zeilennummern +pad.settings.fontType = Schriftart: +pad.settings.fontType.normal = Normal +pad.settings.fontType.monospaced = Monospace +pad.settings.globalView = Gemeinsame Ansicht +pad.settings.language = Sprache: +pad.importExport.import_export = Import/Export +pad.importExport.import = Datei oder Dokument hochladen +pad.importExport.successful = Erfolgreich! +; Fuzzy +pad.importExport.export = Dieses Pad exportieren +pad.importExport.exporthtml = HTML +pad.importExport.exportplain = Reiner Text +pad.importExport.exportword = Microsoft Word +pad.importExport.exportpdf = PDf +pad.importExport.exportopen = ODF (Open Document Format) +pad.importExport.exportdokuwiki = DokuWiki +pad.modals.connected = Verbunden. +pad.modals.reconnecting = Wiederherstellen der Verbindung... +pad.modals.forcereconnect = Erneut Verbinden +pad.modals.uderdup = In einem anderen Fenster geöffnet +pad.modals.userdup.explanation = Dieses Pad scheint in mehr als einem Browser-Fenster auf diesem Computer geöffnet zu sein. +pad.modals.userdup.advice = Um dieses Fenster zu benutzen, verbinden Sie bitte erneut. +pad.modals.unauth = Nicht Authorisiert. +pad.modals.unauth.explanation = Ihre Befugnisse auf dieses Pad zuzugreifen haben sich geädert. Versuchen Sie, erneut zu verbinden. +pad.modals.looping = Verbindung unterbrochen. +pad.modals.looping.explanation = Es gibt Probleme bei der Kommunikation mit dem Synchronisationsserver. +pad.modals.looping.cause = Möglicherweise verläuft Ihre Verbindung durch eine inkompatible Firewall oder einen inkompatiblen Proxy. +pad.modals.initsocketfail = Server nicht erreichbar. +pad.modals.initsocketfail.explanation = Es konnte keine Verbindung zum Synchronisationsserver hergestellt werden. +pad.modals.initsocketfail.cause = Dies könnte an Ihrem Browser oder Ihrer Internet-Verbindung liegen. +pad.modals.slowcommit = Verbindung unterbrochen. +pad.modals.slowcommit.explanation = Der Server reagiert nicht. +pad.modals.slowcommit.cause = Dies könnte an Problemen mit Netzwerk-Konnektivität liegen. Möglicherweise ist der Server aber auch überlastet. +pad.modals.deleted = Entfernt. +pad.modals.deleted.explanation = Dieses Pad wurde entfernt. +pad.modals.disconnected = Verbindung unterbrochen. +pad.modals.disconnected.explanation = Die Verbindung zum Synchronisationsserver wurde unterbrochen. +pad.modals.disconnected.cause = Möglicherweise ist der Server nicht erreichbar. Bitte benachrichtigen Sie uns, falls dies weiterhin passiert. +pad.share = Dieses Pad teilen +pad.share.readonly = Eingeschränkter zugriff (Nur lesen) +pad.share.link = Link +pad.share.emebdcode = In Webseite einbetten +pad.chat = Chat +pad.chat.title = Den Chat für dieses Pad öffnen +timeslider.pageTitle = {{appTitle}} Pad-Geschichte +timeslider.toolbar.returnbutton = Zurück zum Pad +timeslider.toolbar.authors = Autoren: +timeslider.toolbar.authorsList = keine Autoren +timeslider.exportCurrent = Exportiere diese Version als: diff --git a/src/locales/en.ini b/src/locales/en.ini new file mode 100644 index 00000000..a110583e --- /dev/null +++ b/src/locales/en.ini @@ -0,0 +1,77 @@ +[*] +index.newPad = New Pad +index.createOpenPad = or create/open a Pad with the name: +pad.toolbar.bold.title = Bold (Ctrl-B) +pad.toolbar.italic.title = Italic (Ctrl-I) +pad.toolbar.underline.title = Underline (Ctrl-U) +pad.toolbar.strikethrough.title = Strikethrough +pad.toolbar.ol.title = Ordered list +pad.toolbar.ul.title = Unordered List +pad.toolbar.indent.title = Indent +pad.toolbar.unindent.title = Outdent +pad.toolbar.undo.title = Undo (Ctrl-Z) +pad.toolbar.redo.title = Redo (Ctrl-Y) +pad.toolbar.clearAuthorship.title = Clear Authorship Colors +pad.toolbar.import_export.title = Import/Export from/to different file formats +pad.toolbar.timeslider.title = Timeslider +pad.toolbar.savedRevision.title = Saved Revisions +pad.toolbar.settings.title = Settings +pad.toolbar.embed.title = Embed this pad +pad.toolbar.showusers.title = Show the users on this pad +pad.colorpicker.save = Save +pad.colorpicker.cancel = Cancel +pad.loading = Loading... +pad.settings.padSettings = Pad Settings +pad.settings.myView = My View +pad.settings.stickychat = Chat always on screen +pad.settings.colorcheck = Authorship colors +pad.settings.linenocheck = Line numbers +pad.settings.fontType = Font type: +pad.settings.fontType.normal = Normal +pad.settings.fontType.monospaced = Monospace +pad.settings.globalView = Global View +pad.settings.language = Language: +pad.importExport.import_export = Import/Export +pad.importExport.import = Upload any text file or document +pad.importExport.successful = Successful! +pad.importExport.export = Export current pad as: +pad.importExport.exporthtml = HTML +pad.importExport.exportplain = Plain text +pad.importExport.exportword = Microsoft Word +pad.importExport.exportpdf = PDF +pad.importExport.exportopen = ODF (Open Document Format) +pad.importExport.exportdokuwiki = DokuWiki +pad.modals.connected = Connected. +pad.modals.reconnecting = Reconnecting to your pad.. +pad.modals.forcereconnect = Force reconnect +pad.modals.uderdup = Opened in another window +pad.modals.userdup.explanation = This pad seems to be opened in more than one browser window on this computer. +pad.modals.userdup.advice = Reconnect to use this window instead. +pad.modals.unauth = Not authorized +pad.modals.unauth.explanation = Your permissions have changed while viewing this page. Try to reconnect. +pad.modals.looping = Disconnected. +pad.modals.looping.explanation = There are communication problems with the synchronization server. +pad.modals.looping.cause = Perhaps you connected through an incompatible firewall or proxy. +pad.modals.initsocketfail = Server is unreachable. +pad.modals.initsocketfail.explanation = Couldn't connect to the synchronization server. +pad.modals.initsocketfail.cause = This is probably due to a problem with your browser or your internet connection. +pad.modals.slowcommit = Disconnected. +pad.modals.slowcommit.explanation = The server is not responding. +pad.modals.slowcommit.cause = This could be due to problems with network connectivity. +pad.modals.deleted = Deleted. +pad.modals.deleted.explanation = This pad has been removed. +pad.modals.disconnected = You have been disconnected. +pad.modals.disconnected.explanation = The connection to the server was lost +pad.modals.disconnected.cause = The server may be unavailable. Please notify us if this continues to happen. +pad.share = Share this pad +pad.share.readonly = Read only +pad.share.link = Link +pad.share.emebdcode = Embed URL +pad.chat = Chat +pad.chat.title = Open the chat for this pad. + +timeslider.pageTitle = {{appTitle}} Timeslider +timeslider.toolbar.returnbutton = Return to pad +timeslider.toolbar.authors = Authors: +timeslider.toolbar.authorsList = No Authors +timeslider.exportCurrent = Export current version as: \ No newline at end of file diff --git a/src/locales/es.ini b/src/locales/es.ini new file mode 100644 index 00000000..acb6a5cf --- /dev/null +++ b/src/locales/es.ini @@ -0,0 +1,78 @@ +; Exported from translatewiki.net +[es] +index.newPad = Nuevo Pad +index.createOpenPad = o puedes crear/abrir un Pad con el nombre: +pad.toolbar.bold.title = Negrita (Ctrl-B) +pad.toolbar.italic.title = Cursiva (Ctrl-I) +pad.toolbar.underline.title = Subrayado (Ctrl-U) +pad.toolbar.strikethrough.title = Tachado +pad.toolbar.ol.title = Lista ordenada +pad.toolbar.ul.title = Lista desordenada +pad.toolbar.indent.title = Sangrar +pad.toolbar.unindent.title = Desangrar +pad.toolbar.undo.title = Deshacer (Ctrl-Z) +pad.toolbar.redo.title = Rehacer (Ctrl-Y) +pad.toolbar.clearAuthorship.title = Eliminar los colores de los autores +pad.toolbar.import_export.title = Importar/Exportar a diferentes formatos de archivos +pad.toolbar.timeslider.title = Línea de tiempo +pad.toolbar.savedRevision.title = Revisiones guardadas +pad.toolbar.settings.title = Configuración +pad.toolbar.embed.title = Incrustar este pad +pad.toolbar.showusers.title = Mostrar los usuarios de este pad +pad.colorpicker.save = Guardar +pad.colorpicker.cancel = Cancelar +pad.loading = Cargando... +pad.settings.padSettings = Configuración del Pad +pad.settings.myView = Mi vista +pad.settings.stickychat = Chat siempre encima +pad.settings.colorcheck = Color de autoría +pad.settings.linenocheck = Números de línea +pad.settings.fontType = Tipografía: +pad.settings.fontType.normal = Normal +pad.settings.fontType.monospaced = Monoespacio +pad.settings.globalView = Vista global +pad.settings.language = Idioma: +pad.importExport.import_export = Importar/Exportar +pad.importExport.import = Subir cualquier texto o documento +pad.importExport.successful = ¡Operación exitosa! +; Fuzzy +pad.importExport.export = Exporta el pad actual como +pad.importExport.exporthtml = HTML +pad.importExport.exportplain = Texto plano +pad.importExport.exportword = Microsoft Word +pad.importExport.exportpdf = PDF +pad.importExport.exportopen = ODF (Open Document Format) +pad.importExport.exportdokuwiki = DokuWiki +pad.modals.connected = Conectado. +pad.modals.reconnecting = Reconectando a tu pad.. +pad.modals.forcereconnect = Reconexión forzosa +pad.modals.uderdup = Abrir en otra ventana +pad.modals.userdup.explanation = Este pad parece estar abierto en más de una ventana de tu navegador. +pad.modals.userdup.advice = Reconectar para usar esta ventana. +pad.modals.unauth = No autorizado. +pad.modals.unauth.explanation = Los permisos han cambiado mientras estabas viendo esta página. Intenta reconectar de nuevo. +pad.modals.looping = Desconectado. +pad.modals.looping.explanation = Estamos teniendo problemas con la sincronización en el servidor. +pad.modals.looping.cause = Quizás su conexión fluya a través de un proxy o un cortafuegos incompatible. +pad.modals.initsocketfail = Servidor incalcanzable. +pad.modals.initsocketfail.explanation = No se pudo conectar al sevidor de sincronización. +pad.modals.initsocketfail.cause = Puede ser a causa de tu navegador o de una caída en tu conexión de Internet. +pad.modals.slowcommit = Desconectado. +pad.modals.slowcommit.explanation = El servidor no responde. +pad.modals.slowcommit.cause = Puede deberse a problemas con tu conexión de red. +pad.modals.deleted = Borrado. +pad.modals.deleted.explanation = Este pad ha sido borrado. +pad.modals.disconnected = Has sido desconectado. +pad.modals.disconnected.explanation = Se perdió la conexión con el servidor +pad.modals.disconnected.cause = El servidor podría no estar disponible. Contacte con nosotros si esto continúa sucediendo. +pad.share = Compatir el pad +pad.share.readonly = Sólo lectura +pad.share.link = Enlace +pad.share.emebdcode = Incrustar URL +pad.chat = Chat +pad.chat.title = Abrir el chat para este pad. +timeslider.pageTitle = {{appTitle}} Línea de tiempo +timeslider.toolbar.returnbutton = Volver al pad +timeslider.toolbar.authors = Autores: +timeslider.toolbar.authorsList = Sin autores +timeslider.exportCurrent = Exportar la versión actual como: diff --git a/src/locales/fi.ini b/src/locales/fi.ini new file mode 100644 index 00000000..cbaea885 --- /dev/null +++ b/src/locales/fi.ini @@ -0,0 +1,49 @@ +; Exported from translatewiki.net +; Author: Nike +[fi] +index.newPad = Uusi muistio +index.createOpenPad = tai avaa muistio nimellä: +pad.toolbar.bold.title = Lihavointi (Ctrl-B) +pad.toolbar.italic.title = Kursivointi (Ctrl-I) +pad.toolbar.underline.title = Alleviivaus (Ctrl-U) +pad.toolbar.strikethrough.title = Yliviivaus +pad.toolbar.ol.title = Numeroitu lista +pad.toolbar.ul.title = Numeroimaton lista +pad.toolbar.indent.title = Sisennä +pad.toolbar.unindent.title = Ulonna +pad.toolbar.undo.title = Kumoa (Ctrl-Z) +pad.toolbar.redo.title = Tee uudelleen (Ctrl-Y) +pad.toolbar.clearAuthorship.title = Poista kirjoittavärit +pad.toolbar.import_export.title = Tuo tai vie eri muotoihin +pad.toolbar.savedRevision.title = Tallennetut versiot +pad.toolbar.settings.title = Asetukset +pad.toolbar.embed.title = Upota muistio +pad.toolbar.showusers.title = Näytä muistion käyttäjät +pad.colorpicker.save = Tallenna +pad.colorpicker.cancel = Peru +pad.loading = Ladataan… +pad.settings.padSettings = Muistion asetukset +pad.settings.myView = Oma näkymä +pad.settings.stickychat = Keskustelu aina näkyvissä +pad.settings.colorcheck = Kirjoittavärit +pad.settings.linenocheck = Rivinumerot +pad.settings.fontType = Kirjasintyyppi: +pad.settings.fontType.normal = normaali +pad.settings.fontType.monospaced = tasalevyinen +pad.settings.language = Kieli: +pad.importExport.import_export = Tuonti/vienti +pad.importExport.exporthtml = HTML +pad.importExport.exportplain = Muotoilematon teksti +pad.importExport.exportword = Microsoft Word +pad.importExport.exportpdf = PDF +pad.importExport.exportopen = ODF (Open Document Format) +pad.importExport.exportdokuwiki = DokuWiki +pad.modals.connected = Yhdistetty. +pad.modals.reconnecting = Herätellään yhteyttä muistioon... +pad.modals.forcereconnect = Pakota uudelleenyhdistäminen +pad.share = Jaa muistio +pad.share.readonly = Vain luku +pad.share.link = Linkki +pad.share.emebdcode = Upotusosoite +pad.chat = Keskustelu +timeslider.toolbar.returnbutton = Palaa muistioon diff --git a/src/locales/fr.ini b/src/locales/fr.ini new file mode 100644 index 00000000..a80ff205 --- /dev/null +++ b/src/locales/fr.ini @@ -0,0 +1,78 @@ +; Exported from translatewiki.net +[fr] +index.newPad = Nouveau Pad +index.createOpenPad = ou créer/ouvrir un Pad intitulé +pad.toolbar.bold.title = Gras (Ctrl-B) +pad.toolbar.italic.title = Italique (Ctrl-I) +pad.toolbar.underline.title = Souligner (Ctrl-U) +pad.toolbar.strikethrough.title = Barrer +pad.toolbar.ol.title = Liste ordonnée +pad.toolbar.ul.title = Liste non-ordonnée +pad.toolbar.indent.title = Indenter +pad.toolbar.unindent.title = Désindenter +pad.toolbar.undo.title = Annuler (Ctrl-Z) +pad.toolbar.redo.title = Rétablir (Ctrl-Y) +pad.toolbar.clearAuthorship.title = Effacer les couleurs identifiant les auteurs +pad.toolbar.import_export.title = Importer/Exporter de/vers un format de fichier différent +pad.toolbar.timeslider.title = Navigateur d'historique +pad.toolbar.savedRevision.title = Versions enregistrées +pad.toolbar.settings.title = Paramètres +pad.toolbar.embed.title = Intégrer ce Pad +pad.toolbar.showusers.title = Afficher les utilisateurs du Pad +pad.colorpicker.save = Sauver +pad.colorpicker.cancel = Annuler +pad.loading = Chargement... +pad.settings.padSettings = Paramètres du Pad +pad.settings.myView = Ma vue +pad.settings.stickychat = Messagerie toujours affichée +pad.settings.colorcheck = Couleurs d'identification +pad.settings.linenocheck = Numéros des lignes +pad.settings.fontType = Type de police: +pad.settings.fontType.normal = Normal +pad.settings.fontType.monospaced = Monospace +pad.settings.globalView = Vue d'ensemble +pad.settings.language = Langue: +pad.importExport.import_export = Importer/Exporter +pad.importExport.import = Charger un texte ou un document +pad.importExport.successful = Traitement effectué! +; Fuzzy +pad.importExport.export = Exporter ce Pad vers +pad.importExport.exporthtml = HTML +pad.importExport.exportplain = Texte brut +pad.importExport.exportword = Microsoft Word +pad.importExport.exportpdf = PDf +pad.importExport.exportopen = ODF (Open Document Format) +pad.importExport.exportdokuwiki = DokuWiki +pad.modals.connected = Connecté. +pad.modals.reconnecting = Reconnexion vers votre Pad... +pad.modals.forcereconnect = Forcer la reconnexion. +pad.modals.uderdup = Ouvrir dans une nouvelle fenêtre +pad.modals.userdup.explanation = Ce Pad semble avoir été ouvert dans plusieurs fenêtres de votre fureteur sur cet ordinateur. +pad.modals.userdup.advice = Se reconnecter en utilisant cette fenêtre. +pad.modals.unauth = Not authorized Non authorisé +pad.modals.unauth.explanation = Vos permissions ont été changées lors de la visualisation de cette page. Essayer de vous reconnecter. +pad.modals.looping = Disconnected. Déconnecté. +pad.modals.looping.explanation = Nous éprouvons un problème de communication au serveur de synchronisation. +pad.modals.looping.cause = Il est possible que leur connection soit protégée par un pare-feu incompatible ou un serveur proxy incompatible. +pad.modals.initsocketfail = Le serveur est introuvable. +pad.modals.initsocketfail.explanation = Impossible de se connecter au serveur de synchronisation. +pad.modals.initsocketfail.cause = La cause de ce problème peut être liée à votre fureteur web. +pad.modals.slowcommit = Disconnected. Déconnecté +pad.modals.slowcommit.explanation = Le serveur ne répond pas. +pad.modals.slowcommit.cause = La cause de ce problème peut être liée à une erreur de connectivité du réseau. +pad.modals.deleted = Supprimé. +pad.modals.deleted.explanation = Ce Pad a été supprimé. +pad.modals.disconnected = Vous avez été déconnecté. +pad.modals.disconnected.explanation = La connexion au serveur a échoué. +pad.modals.disconnected.cause = Ce serveur est possiblement hors-ligne. Veuillez nous joindre si le problème persiste. +pad.share = Partager ce Pad +pad.share.readonly = Lecture seule +pad.share.link = Lien +pad.share.emebdcode = Lien à intégrer +pad.chat = Messagerie +pad.chat.title = Ouvrir la messagerie liée au Pad. +timeslider.pageTitle = {{appTitle}} Curseur temporel +timeslider.toolbar.returnbutton = Retour à ce Pad. +timeslider.toolbar.authors = Auteurs: +timeslider.toolbar.authorsList = Aucun auteurs +timeslider.exportCurrent = Exporter version actuelle vers: diff --git a/src/locales/nl.ini b/src/locales/nl.ini new file mode 100644 index 00000000..87eaeb13 --- /dev/null +++ b/src/locales/nl.ini @@ -0,0 +1,79 @@ +; Exported from translatewiki.net +; Author: Siebrand +[nl] +index.newPad = Nieuw pad +index.createOpenPad = Maak of open pad met de naam: +pad.toolbar.bold.title = Vet (Ctrl-B) +pad.toolbar.italic.title = Cursief (Ctrl-I) +pad.toolbar.underline.title = Onderstrepen (Ctrl-U) +pad.toolbar.strikethrough.title = Doorhalen +pad.toolbar.ol.title = Geordende lijst +pad.toolbar.ul.title = Ongeordende lijst +pad.toolbar.indent.title = Inspringen +pad.toolbar.unindent.title = Inspringing verkleinen +pad.toolbar.undo.title = Ongedaan maken (Ctrl-Z) +pad.toolbar.redo.title = Opnieuw uitvoeren (Ctrl-Y) +pad.toolbar.clearAuthorship.title = Kleuren auteurs wissen +pad.toolbar.import_export.title = Naar/van andere opmaak exporteren/importeren +pad.toolbar.timeslider.title = Tijdlijn +pad.toolbar.savedRevision.title = Opgeslagen versies +pad.toolbar.settings.title = Instellingen +pad.toolbar.embed.title = Pad insluiten +pad.toolbar.showusers.title = Gebruikers van dit pad weergeven +pad.colorpicker.save = Opslaan +pad.colorpicker.cancel = Annuleren +pad.loading = Bezig met laden… +pad.settings.padSettings = Padinstellingen +pad.settings.myView = Mijn overzicht +pad.settings.stickychat = Chat altijd zichtbaar +pad.settings.colorcheck = Kleuren auteurs +pad.settings.linenocheck = Regelnummers +pad.settings.fontType = Lettertype: +pad.settings.fontType.normal = Normaal +pad.settings.fontType.monospaced = Monospace +pad.settings.globalView = Globaal overzicht +pad.settings.language = Taal: +pad.importExport.import_export = Importeren/exporteren +pad.importExport.import = Upload een tekstbestand of document +pad.importExport.successful = Afgerond +; Fuzzy +pad.importExport.export = Huidige pad exporteren als +pad.importExport.exporthtml = HTML +pad.importExport.exportplain = Tekst zonder opmaak +pad.importExport.exportword = Microsoft Word +pad.importExport.exportpdf = PDF +pad.importExport.exportopen = ODF (Open Document Format) +pad.importExport.exportdokuwiki = DokuWiki +pad.modals.connected = Verbonden. +pad.modals.reconnecting = Opnieuw verbinding maken met uw pad... +pad.modals.forcereconnect = Opnieuw verbinden +pad.modals.uderdup = Openen in ander venster +pad.modals.userdup.explanation = Dit pad is meer dan één keer geopend in een browservenster op deze computer. +pad.modals.userdup.advice = Opnieuw verbinden en dit venster gebruiken. +pad.modals.unauth = Niet toegestaan +pad.modals.unauth.explanation = Uw rechten zijn gewijzigd terwijl u de pagina aan het bekijken was. Probeer opnieuw te verbinden. +pad.modals.looping = Verbinding verbroken. +pad.modals.looping.explanation = Er is een probleem opgetreden tijdens de communicatie met de synchronisatieserver. +pad.modals.looping.cause = Mogelijk gebruikt de server een niet compatibele firewall of proxy server. +pad.modals.initsocketfail = Server is niet bereikbaar. +pad.modals.initsocketfail.explanation = Het was niet mogelijk te verbinden met de synchronisatieserver. +pad.modals.initsocketfail.cause = Mogelijk komt dit door uw browser of internetverbinding. +pad.modals.slowcommit = Verbinding verbroken. +pad.modals.slowcommit.explanation = De server reageert niet. +pad.modals.slowcommit.cause = Dit komt mogelijk door netwerkproblemen. +pad.modals.deleted = Verwijderd. +pad.modals.deleted.explanation = Dit pad is verwijderd. +pad.modals.disconnected = Uw verbinding is verbroken. +pad.modals.disconnected.explanation = De verbinding met de server is verbroken +pad.modals.disconnected.cause = De server is mogelijk niet beschikbaar. Stel alstublieft de beheerder op de hoogte. +pad.share = Pad delen +pad.share.readonly = Alleen-lezen +pad.share.link = Verwijzing +pad.share.emebdcode = URL insluiten +pad.chat = Chatten +pad.chat.title = Chat voor dit pad opnenen +timeslider.pageTitle = Tijdlijn voor {{appTitle}} +timeslider.toolbar.returnbutton = Terug naar pad +timeslider.toolbar.authors = Auteurs: +timeslider.toolbar.authorsList = Geen auteurs +timeslider.exportCurrent = Huidige versie exporteren als: diff --git a/src/node/handler/PadMessageHandler.js b/src/node/handler/PadMessageHandler.js index a30e4e81..a0bccfc5 100644 --- a/src/node/handler/PadMessageHandler.js +++ b/src/node/handler/PadMessageHandler.js @@ -619,7 +619,7 @@ exports.updatePadClients = function(pad, callback) //https://github.com/caolan/async#whilst //send them all new changesets async.whilst( - function (){ return sessioninfos[session].rev < pad.getHeadRevisionNumber()}, + function (){ return sessioninfos[session] && sessioninfos[session].rev < pad.getHeadRevisionNumber()}, function(callback) { var author, revChangeset, currentTime; diff --git a/src/node/hooks/express/tests.js b/src/node/hooks/express/tests.js new file mode 100644 index 00000000..94cd5fb6 --- /dev/null +++ b/src/node/hooks/express/tests.js @@ -0,0 +1,47 @@ +var path = require("path") + , npm = require("npm") + , fs = require("fs"); + +exports.expressCreateServer = function (hook_name, args, cb) { + args.app.get('/tests/frontend/specs_list.js', function(req, res){ + fs.readdir('tests/frontend/specs', function(err, files){ + if(err){ return res.send(500); } + + res.send("var specs_list = " + JSON.stringify(files.sort()) + ";\n"); + }); + }); + + var url2FilePath = function(url){ + var subPath = url.substr("/tests/frontend".length); + if (subPath == ""){ + subPath = "index.html" + } + subPath = subPath.split("?")[0]; + + var filePath = path.normalize(npm.root + "/../tests/frontend/") + filePath += subPath.replace("..", ""); + return filePath; + } + + args.app.get('/tests/frontend/specs/*', function (req, res) { + var specFilePath = url2FilePath(req.url); + var specFileName = path.basename(specFilePath); + + fs.readFile(specFilePath, function(err, content){ + if(err){ return res.send(500); } + + content = "describe(" + JSON.stringify(specFileName) + ", function(){ " + content + " });"; + + res.send(content); + }); + }); + + args.app.get('/tests/frontend/*', function (req, res) { + var filePath = url2FilePath(req.url); + res.sendfile(filePath); + }); + + args.app.get('/tests/frontend', function (req, res) { + res.redirect('/tests/frontend/'); + }); +} \ No newline at end of file diff --git a/src/node/hooks/i18n.js b/src/node/hooks/i18n.js new file mode 100644 index 00000000..4d42de04 --- /dev/null +++ b/src/node/hooks/i18n.js @@ -0,0 +1,35 @@ +var languages = require('languages') + , fs = require('fs') + , path = require('path') + , express = require('express') + +var localesPath = __dirname+"/../../locales"; + +// Serve English strings directly with /locales.ini +var localeIndex = fs.readFileSync(localesPath+'/en.ini')+'\r\n'; + +// add language base 'en' to availableLangs +exports.availableLangs = {en: languages.getLanguageInfo('en')} + +fs.readdir(localesPath, function(er, files) { + files.forEach(function(locale) { + locale = locale.split('.')[0] + if(locale.toLowerCase() == 'en') return; + + // build locale index + localeIndex += '['+locale+']\r\n@import url(locales/'+locale+'.ini)\r\n' + + // add info language {name, nativeName, direction} to availableLangs + exports.availableLangs[locale]=languages.getLanguageInfo(locale); + }) +}) + +exports.expressCreateServer = function(n, args) { + + args.app.use('/locales', express.static(localesPath)); + + args.app.get('/locales.ini', function(req, res) { + res.send(localeIndex); + }) + +} diff --git a/src/node/padaccess.js b/src/node/padaccess.js index 4388ab94..d8780914 100644 --- a/src/node/padaccess.js +++ b/src/node/padaccess.js @@ -7,7 +7,7 @@ module.exports = function (req, res, callback) { // FIXME: Why is this ever undefined?? if (req.cookies === undefined) req.cookies = {}; - securityManager.checkAccess(req.params.pad, req.cookies.sessionid, req.cookies.token, req.cookies.password, function(err, accessObj) { + securityManager.checkAccess(req.params.pad, req.cookies.sessionID, req.cookies.token, req.cookies.password, function(err, accessObj) { if(ERR(err, callback)) return; //there is access, continue diff --git a/src/package.json b/src/package.json index c3c4968a..9fd180ab 100644 --- a/src/package.json +++ b/src/package.json @@ -17,7 +17,7 @@ "resolve" : "0.2.x", "socket.io" : "0.9.x", "ueberDB" : "0.1.8", - "async" : "0.1.22", + "async" : "0.1.x", "express" : "3.x", "connect" : "2.4.x", "clean-css" : "0.3.2", @@ -35,14 +35,16 @@ "security" : "1.0.0", "tinycon" : "0.0.1", "underscore" : "1.3.1", - "unorm" : "1.0.0" + "unorm" : "1.0.0", + "languages" : "0.1.1" }, "bin": { "etherpad-lite": "./node/server.js" }, "devDependencies": { - "jshint" : "*" + "jshint" : "*", + "wd" : "0.0.26" }, "engines" : { "node" : ">=0.6.0", "npm" : ">=1.0" }, - "version" : "1.1.4" + "version" : "1.2.0" } diff --git a/src/static/css/iframe_editor.css b/src/static/css/iframe_editor.css index bca00ff4..5134fcdb 100644 --- a/src/static/css/iframe_editor.css +++ b/src/static/css/iframe_editor.css @@ -126,7 +126,7 @@ body.doesWrap { .sidedivdelayed { /* class set after sizes are set */ background-color: #eee; color: #888 !important; - border-right: 1px solid #999; + border-right: 1px solid #ccc; } .sidedivhidden { display: none; diff --git a/src/static/css/pad.css b/src/static/css/pad.css index 5ee6b3c5..64f9f0d4 100644 --- a/src/static/css/pad.css +++ b/src/static/css/pad.css @@ -748,6 +748,15 @@ input[type=checkbox] { .popup p { margin: 5px 0 } +.popup select { + background: #fff; + padding: 2px; + height: 24px; + border-radius: 3px; + border: 1px solid #ccc; + outline: none; + min-width: 105px; +} .column { float: left; width: 50%; diff --git a/src/static/js/chat.js b/src/static/js/chat.js index 486c7256..4493ed15 100644 --- a/src/static/js/chat.js +++ b/src/static/js/chat.js @@ -150,7 +150,7 @@ var chat = (function() $("#chatinput").keypress(function(evt) { //if the user typed enter, fire the send - if(evt.which == 13) + if(evt.which == 13 || evt.which == 10) { evt.preventDefault(); self.send(); diff --git a/src/static/js/l10n.js b/src/static/js/l10n.js new file mode 100644 index 00000000..ef8218d3 --- /dev/null +++ b/src/static/js/l10n.js @@ -0,0 +1,1028 @@ +/** Copyright (c) 2011-2012 Fabien Cazenave, Mozilla. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +/*jshint browser: true, devel: true, es5: true, globalstrict: true */ +'use strict'; + +document.webL10n = (function(window, document, undefined) { + var gL10nData = {}; + var gTextData = ''; + var gTextProp = 'textContent'; + var gLanguage = ''; + var gMacros = {}; + var gReadyState = 'loading'; + + // read-only setting -- we recommend to load l10n resources synchronously + var gAsyncResourceLoading = true; + + // debug helpers + var gDEBUG = false; + function consoleLog(message) { + if (gDEBUG) + console.log('[l10n] ' + message); + }; + function consoleWarn(message) { + if (gDEBUG) + console.warn('[l10n] ' + message); + }; + + /** + * DOM helpers for the so-called "HTML API". + * + * These functions are written for modern browsers. For old versions of IE, + * they're overridden in the 'startup' section at the end of this file. + */ + + function getL10nResourceLinks() { + return document.querySelectorAll('link[type="application/l10n"]'); + } + + function getTranslatableChildren(element) { + return element ? element.querySelectorAll('*[data-l10n-id]') : []; + } + + function getL10nAttributes(element) { + if (!element) + return {}; + + var l10nId = element.getAttribute('data-l10n-id'); + var l10nArgs = element.getAttribute('data-l10n-args'); + var args = {}; + if (l10nArgs) { + try { + args = JSON.parse(l10nArgs); + } catch (e) { + consoleWarn('could not parse arguments for #' + l10nId); + } + } + return { id: l10nId, args: args }; + } + + function fireL10nReadyEvent(lang) { + var evtObject = document.createEvent('Event'); + evtObject.initEvent('localized', false, false); + evtObject.language = lang; + window.dispatchEvent(evtObject); + } + + + /** + * l10n resource parser: + * - reads (async XHR) the l10n resource matching `lang'; + * - imports linked resources (synchronously) when specified; + * - parses the text data (fills `gL10nData' and `gTextData'); + * - triggers success/failure callbacks when done. + * + * @param {string} href + * URL of the l10n resource to parse. + * + * @param {string} lang + * locale (language) to parse. + * + * @param {Function} successCallback + * triggered when the l10n resource has been successully parsed. + * + * @param {Function} failureCallback + * triggered when the an error has occured. + * + * @return {void} + * uses the following global variables: gL10nData, gTextData, gTextProp. + */ + + function parseResource(href, lang, successCallback, failureCallback) { + var baseURL = href.replace(/\/[^\/]*$/, '/'); + + // handle escaped characters (backslashes) in a string + function evalString(text) { + if (text.lastIndexOf('\\') < 0) + return text; + return text.replace(/\\\\/g, '\\') + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\b/g, '\b') + .replace(/\\f/g, '\f') + .replace(/\\{/g, '{') + .replace(/\\}/g, '}') + .replace(/\\"/g, '"') + .replace(/\\'/g, "'"); + } + + // parse *.properties text data into an l10n dictionary + function parseProperties(text) { + var dictionary = {}; + + // token expressions + var reBlank = /^\s*|\s*$/; + var reComment = /^\s*;|^\s*$/;// Use ; for comments! + var reSection = /^\s*\[(.*)\]\s*$/; + var reImport = /^\s*@import\s+url\((.*)\)\s*$/i; + var reSplit = /^([^=\s]*)\s*=\s*(.+)$/; // TODO: escape EOLs with '\' + + // parse the *.properties file into an associative array + function parseRawLines(rawText, extendedSyntax) { + var entries = rawText.replace(reBlank, '').split(/[\r\n]+/); + var currentLang = '*'; + var genericLang = lang.replace(/-[a-z]+$/i, ''); + var skipLang = false; + var match = ''; + + for (var i = 0; i < entries.length; i++) { + var line = entries[i]; + + // comment or blank line? + if (reComment.test(line)) + continue; + + // the extended syntax supports [lang] sections and @import rules + if (extendedSyntax) { + if (reSection.test(line)) { // section start? + match = reSection.exec(line); + currentLang = match[1]; + skipLang = (currentLang !== '*') && + (currentLang !== lang) && (currentLang !== genericLang); + continue; + } else if (skipLang) { + continue; + } + if (reImport.test(line)) { // @import rule? + match = reImport.exec(line); + loadImport(baseURL + match[1]); // load the resource synchronously + } + } + + // key-value pair + consoleLog(tmp) + var tmp = line.match(reSplit); + if (tmp && tmp.length == 3) + dictionary[tmp[1]] = evalString(tmp[2]); + } + } + + // import another *.properties file + function loadImport(url) { + loadResource(url, function(content) { + parseRawLines(content, false); // don't allow recursive imports + }, false, false); // load synchronously + } + + // fill the dictionary + parseRawLines(text, true); + return dictionary; + } + + // load the specified resource file + function loadResource(url, onSuccess, onFailure, asynchronous) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, asynchronous); + if (xhr.overrideMimeType) { + xhr.overrideMimeType('text/plain; charset=utf-8'); + } + xhr.onreadystatechange = function() { + if (xhr.readyState == 4) { + if (xhr.status == 200 || xhr.status === 0) { + if (onSuccess) + onSuccess(xhr.responseText); + } else { + if (onFailure) + onFailure(); + } + } + }; + xhr.send(null); + } + + // load and parse l10n data (warning: global variables are used here) + loadResource(href, function(response) { + gTextData += response; // mostly for debug + + // parse *.properties text data into an l10n dictionary + var data = parseProperties(response); + + // allowed attributes + var attrList = + { "title": 1 + , "innerHTML": 1 + , "alt": 1 + , "textContent": 1 + } + + // find attribute descriptions, if any + for (var key in data) { + var id, prop, index = key.lastIndexOf('.'); + if (index > 0 && key.substr(index + 1) in attrList) { // an attribute has been specified + id = key.substring(0, index); + prop = key.substr(index + 1); + } else { // no attribute: assuming text content by default + id = key; + prop = gTextProp; + } + if (!gL10nData[id]) { + gL10nData[id] = {}; + } + gL10nData[id][prop] = data[key]; + } + + // trigger callback + if (successCallback) + successCallback(); + }, failureCallback, gAsyncResourceLoading); + }; + + // load and parse all resources for the specified locale + function loadLocale(lang, callback) { + clear(); + gLanguage = lang; + + // check all <link type="application/l10n" href="..." /> nodes + // and load the resource files + var langLinks = getL10nResourceLinks(); + var langCount = langLinks.length; + if (langCount == 0) { + consoleLog('no resource to load, early way out'); + fireL10nReadyEvent(lang); + gReadyState = 'complete'; + return; + } + + // start the callback when all resources are loaded + var onResourceLoaded = null; + var gResourceCount = 0; + onResourceLoaded = function() { + gResourceCount++; + if (gResourceCount >= langCount) { + if (callback) // execute the [optional] callback + callback(); + fireL10nReadyEvent(lang); + gReadyState = 'complete'; + } + }; + + // load all resource files + function l10nResourceLink(link) { + var href = link.href; + var type = link.type; + this.load = function(lang, callback) { + var applied = lang; + parseResource(href, lang, callback, function() { + consoleWarn(href + ' not found.'); + applied = ''; + }); + return applied; // return lang if found, an empty string if not found + }; + } + + for (var i = 0; i < langCount; i++) { + var resource = new l10nResourceLink(langLinks[i]); + var rv = resource.load(lang, onResourceLoaded); + if (rv != lang) { // lang not found, used default resource instead + consoleWarn('"' + lang + '" resource not found'); + gLanguage = ''; + } + } + } + + // clear all l10n data + function clear() { + gL10nData = {}; + gTextData = ''; + gLanguage = ''; + // TODO: clear all non predefined macros. + // There's no such macro /yet/ but we're planning to have some... + } + + + /** + * Get rules for plural forms (shared with JetPack), see: + * http://unicode.org/repos/cldr-tmp/trunk/diff/supplemental/language_plural_rules.html + * https://github.com/mozilla/addon-sdk/blob/master/python-lib/plural-rules-generator.p + * + * @param {string} lang + * locale (language) used. + * + * @return {Function} + * returns a function that gives the plural form name for a given integer: + * var fun = getPluralRules('en'); + * fun(1) -> 'one' + * fun(0) -> 'other' + * fun(1000) -> 'other'. + */ + + function getPluralRules(lang) { + var locales2rules = { + 'af': 3, + 'ak': 4, + 'am': 4, + 'ar': 1, + 'asa': 3, + 'az': 0, + 'be': 11, + 'bem': 3, + 'bez': 3, + 'bg': 3, + 'bh': 4, + 'bm': 0, + 'bn': 3, + 'bo': 0, + 'br': 20, + 'brx': 3, + 'bs': 11, + 'ca': 3, + 'cgg': 3, + 'chr': 3, + 'cs': 12, + 'cy': 17, + 'da': 3, + 'de': 3, + 'dv': 3, + 'dz': 0, + 'ee': 3, + 'el': 3, + 'en': 3, + 'eo': 3, + 'es': 3, + 'et': 3, + 'eu': 3, + 'fa': 0, + 'ff': 5, + 'fi': 3, + 'fil': 4, + 'fo': 3, + 'fr': 5, + 'fur': 3, + 'fy': 3, + 'ga': 8, + 'gd': 24, + 'gl': 3, + 'gsw': 3, + 'gu': 3, + 'guw': 4, + 'gv': 23, + 'ha': 3, + 'haw': 3, + 'he': 2, + 'hi': 4, + 'hr': 11, + 'hu': 0, + 'id': 0, + 'ig': 0, + 'ii': 0, + 'is': 3, + 'it': 3, + 'iu': 7, + 'ja': 0, + 'jmc': 3, + 'jv': 0, + 'ka': 0, + 'kab': 5, + 'kaj': 3, + 'kcg': 3, + 'kde': 0, + 'kea': 0, + 'kk': 3, + 'kl': 3, + 'km': 0, + 'kn': 0, + 'ko': 0, + 'ksb': 3, + 'ksh': 21, + 'ku': 3, + 'kw': 7, + 'lag': 18, + 'lb': 3, + 'lg': 3, + 'ln': 4, + 'lo': 0, + 'lt': 10, + 'lv': 6, + 'mas': 3, + 'mg': 4, + 'mk': 16, + 'ml': 3, + 'mn': 3, + 'mo': 9, + 'mr': 3, + 'ms': 0, + 'mt': 15, + 'my': 0, + 'nah': 3, + 'naq': 7, + 'nb': 3, + 'nd': 3, + 'ne': 3, + 'nl': 3, + 'nn': 3, + 'no': 3, + 'nr': 3, + 'nso': 4, + 'ny': 3, + 'nyn': 3, + 'om': 3, + 'or': 3, + 'pa': 3, + 'pap': 3, + 'pl': 13, + 'ps': 3, + 'pt': 3, + 'rm': 3, + 'ro': 9, + 'rof': 3, + 'ru': 11, + 'rwk': 3, + 'sah': 0, + 'saq': 3, + 'se': 7, + 'seh': 3, + 'ses': 0, + 'sg': 0, + 'sh': 11, + 'shi': 19, + 'sk': 12, + 'sl': 14, + 'sma': 7, + 'smi': 7, + 'smj': 7, + 'smn': 7, + 'sms': 7, + 'sn': 3, + 'so': 3, + 'sq': 3, + 'sr': 11, + 'ss': 3, + 'ssy': 3, + 'st': 3, + 'sv': 3, + 'sw': 3, + 'syr': 3, + 'ta': 3, + 'te': 3, + 'teo': 3, + 'th': 0, + 'ti': 4, + 'tig': 3, + 'tk': 3, + 'tl': 4, + 'tn': 3, + 'to': 0, + 'tr': 0, + 'ts': 3, + 'tzm': 22, + 'uk': 11, + 'ur': 3, + 've': 3, + 'vi': 0, + 'vun': 3, + 'wa': 4, + 'wae': 3, + 'wo': 0, + 'xh': 3, + 'xog': 3, + 'yo': 0, + 'zh': 0, + 'zu': 3 + }; + + // utility functions for plural rules methods + function isIn(n, list) { + return list.indexOf(n) !== -1; + } + function isBetween(n, start, end) { + return start <= n && n <= end; + } + + // list of all plural rules methods: + // map an integer to the plural form name to use + var pluralRules = { + '0': function(n) { + return 'other'; + }, + '1': function(n) { + if ((isBetween((n % 100), 3, 10))) + return 'few'; + if (n === 0) + return 'zero'; + if ((isBetween((n % 100), 11, 99))) + return 'many'; + if (n == 2) + return 'two'; + if (n == 1) + return 'one'; + return 'other'; + }, + '2': function(n) { + if (n !== 0 && (n % 10) === 0) + return 'many'; + if (n == 2) + return 'two'; + if (n == 1) + return 'one'; + return 'other'; + }, + '3': function(n) { + if (n == 1) + return 'one'; + return 'other'; + }, + '4': function(n) { + if ((isBetween(n, 0, 1))) + return 'one'; + return 'other'; + }, + '5': function(n) { + if ((isBetween(n, 0, 2)) && n != 2) + return 'one'; + return 'other'; + }, + '6': function(n) { + if (n === 0) + return 'zero'; + if ((n % 10) == 1 && (n % 100) != 11) + return 'one'; + return 'other'; + }, + '7': function(n) { + if (n == 2) + return 'two'; + if (n == 1) + return 'one'; + return 'other'; + }, + '8': function(n) { + if ((isBetween(n, 3, 6))) + return 'few'; + if ((isBetween(n, 7, 10))) + return 'many'; + if (n == 2) + return 'two'; + if (n == 1) + return 'one'; + return 'other'; + }, + '9': function(n) { + if (n === 0 || n != 1 && (isBetween((n % 100), 1, 19))) + return 'few'; + if (n == 1) + return 'one'; + return 'other'; + }, + '10': function(n) { + if ((isBetween((n % 10), 2, 9)) && !(isBetween((n % 100), 11, 19))) + return 'few'; + if ((n % 10) == 1 && !(isBetween((n % 100), 11, 19))) + return 'one'; + return 'other'; + }, + '11': function(n) { + if ((isBetween((n % 10), 2, 4)) && !(isBetween((n % 100), 12, 14))) + return 'few'; + if ((n % 10) === 0 || + (isBetween((n % 10), 5, 9)) || + (isBetween((n % 100), 11, 14))) + return 'many'; + if ((n % 10) == 1 && (n % 100) != 11) + return 'one'; + return 'other'; + }, + '12': function(n) { + if ((isBetween(n, 2, 4))) + return 'few'; + if (n == 1) + return 'one'; + return 'other'; + }, + '13': function(n) { + if ((isBetween((n % 10), 2, 4)) && !(isBetween((n % 100), 12, 14))) + return 'few'; + if (n != 1 && (isBetween((n % 10), 0, 1)) || + (isBetween((n % 10), 5, 9)) || + (isBetween((n % 100), 12, 14))) + return 'many'; + if (n == 1) + return 'one'; + return 'other'; + }, + '14': function(n) { + if ((isBetween((n % 100), 3, 4))) + return 'few'; + if ((n % 100) == 2) + return 'two'; + if ((n % 100) == 1) + return 'one'; + return 'other'; + }, + '15': function(n) { + if (n === 0 || (isBetween((n % 100), 2, 10))) + return 'few'; + if ((isBetween((n % 100), 11, 19))) + return 'many'; + if (n == 1) + return 'one'; + return 'other'; + }, + '16': function(n) { + if ((n % 10) == 1 && n != 11) + return 'one'; + return 'other'; + }, + '17': function(n) { + if (n == 3) + return 'few'; + if (n === 0) + return 'zero'; + if (n == 6) + return 'many'; + if (n == 2) + return 'two'; + if (n == 1) + return 'one'; + return 'other'; + }, + '18': function(n) { + if (n === 0) + return 'zero'; + if ((isBetween(n, 0, 2)) && n !== 0 && n != 2) + return 'one'; + return 'other'; + }, + '19': function(n) { + if ((isBetween(n, 2, 10))) + return 'few'; + if ((isBetween(n, 0, 1))) + return 'one'; + return 'other'; + }, + '20': function(n) { + if ((isBetween((n % 10), 3, 4) || ((n % 10) == 9)) && !( + isBetween((n % 100), 10, 19) || + isBetween((n % 100), 70, 79) || + isBetween((n % 100), 90, 99) + )) + return 'few'; + if ((n % 1000000) === 0 && n !== 0) + return 'many'; + if ((n % 10) == 2 && !isIn((n % 100), [12, 72, 92])) + return 'two'; + if ((n % 10) == 1 && !isIn((n % 100), [11, 71, 91])) + return 'one'; + return 'other'; + }, + '21': function(n) { + if (n === 0) + return 'zero'; + if (n == 1) + return 'one'; + return 'other'; + }, + '22': function(n) { + if ((isBetween(n, 0, 1)) || (isBetween(n, 11, 99))) + return 'one'; + return 'other'; + }, + '23': function(n) { + if ((isBetween((n % 10), 1, 2)) || (n % 20) === 0) + return 'one'; + return 'other'; + }, + '24': function(n) { + if ((isBetween(n, 3, 10) || isBetween(n, 13, 19))) + return 'few'; + if (isIn(n, [2, 12])) + return 'two'; + if (isIn(n, [1, 11])) + return 'one'; + return 'other'; + } + }; + + // return a function that gives the plural form name for a given integer + var index = locales2rules[lang.replace(/-.*$/, '')]; + if (!(index in pluralRules)) { + consoleWarn('plural form unknown for [' + lang + ']'); + return function() { return 'other'; }; + } + return pluralRules[index]; + } + + // pre-defined 'plural' macro + gMacros.plural = function(str, param, key, prop) { + var n = parseFloat(param); + if (isNaN(n)) + return str; + + // TODO: support other properties (l20n still doesn't...) + if (prop != gTextProp) + return str; + + // initialize _pluralRules + if (!gMacros._pluralRules) + gMacros._pluralRules = getPluralRules(gLanguage); + var index = '[' + gMacros._pluralRules(n) + ']'; + + // try to find a [zero|one|two] key if it's defined + if (n === 0 && (key + '[zero]') in gL10nData) { + str = gL10nData[key + '[zero]'][prop]; + } else if (n == 1 && (key + '[one]') in gL10nData) { + str = gL10nData[key + '[one]'][prop]; + } else if (n == 2 && (key + '[two]') in gL10nData) { + str = gL10nData[key + '[two]'][prop]; + } else if ((key + index) in gL10nData) { + str = gL10nData[key + index][prop]; + } + + return str; + }; + + + /** + * l10n dictionary functions + */ + + // fetch an l10n object, warn if not found, apply `args' if possible + function getL10nData(key, args) { + var data = gL10nData[key]; + if (!data) { + consoleWarn('#' + key + ' missing for [' + gLanguage + ']'); + } + + /** This is where l10n expressions should be processed. + * The plan is to support C-style expressions from the l20n project; + * until then, only two kinds of simple expressions are supported: + * {[ index ]} and {{ arguments }}. + */ + var rv = {}; + for (var prop in data) { + var str = data[prop]; + str = substIndexes(str, args, key, prop); + str = substArguments(str, args); + rv[prop] = str; + } + return rv; + } + + // replace {[macros]} with their values + function substIndexes(str, args, key, prop) { + var reIndex = /\{\[\s*([a-zA-Z]+)\(([a-zA-Z]+)\)\s*\]\}/; + var reMatch = reIndex.exec(str); + if (!reMatch || !reMatch.length) + return str; + + // an index/macro has been found + // Note: at the moment, only one parameter is supported + var macroName = reMatch[1]; + var paramName = reMatch[2]; + var param; + if (args && paramName in args) { + param = args[paramName]; + } else if (paramName in gL10nData) { + param = gL10nData[paramName]; + } + + // there's no macro parser yet: it has to be defined in gMacros + if (macroName in gMacros) { + var macro = gMacros[macroName]; + str = macro(str, param, key, prop); + } + return str; + } + + // replace {{arguments}} with their values + function substArguments(str, args) { + var reArgs = /\{\{\s*([a-zA-Z\.]+)\s*\}\}/; + var match = reArgs.exec(str); + while (match) { + if (!match || match.length < 2) + return str; // argument key not found + + var arg = match[1]; + var sub = ''; + if (arg in args) { + sub = args[arg]; + } else if (arg in gL10nData) { + sub = gL10nData[arg][gTextProp]; + } else { + consoleWarn('could not find argument {{' + arg + '}}'); + return str; + } + + str = str.substring(0, match.index) + sub + + str.substr(match.index + match[0].length); + match = reArgs.exec(str); + } + return str; + } + + // translate an HTML element + function translateElement(element) { + var l10n = getL10nAttributes(element); + if (!l10n.id) + return; + + // get the related l10n object + var data = getL10nData(l10n.id, l10n.args); + if (!data) { + consoleWarn('#' + l10n.id + ' missing for [' + gLanguage + ']'); + return; + } + + // translate element (TODO: security checks?) + // for the node content, replace the content of the first child textNode + // and clear other child textNodes + if (data[gTextProp]) { // XXX + if (element.children.length === 0) { + element[gTextProp] = data[gTextProp]; + } else { + var children = element.childNodes, + found = false; + for (var i = 0, l = children.length; i < l; i++) { + if (children[i].nodeType === 3 && + /\S/.test(children[i].textContent)) { // XXX + // using nodeValue seems cross-browser + if (found) { + children[i].nodeValue = ''; + } else { + children[i].nodeValue = data[gTextProp]; + found = true; + } + } + } + if (!found) { + consoleWarn('unexpected error, could not translate element content'); + } + } + delete data[gTextProp]; + } + + for (var k in data) { + element[k] = data[k]; + } + } + + // translate an HTML subtree + function translateFragment(element) { + element = element || document.documentElement; + + // check all translatable children (= w/ a `data-l10n-id' attribute) + var children = getTranslatableChildren(element); + var elementCount = children.length; + for (var i = 0; i < elementCount; i++) { + translateElement(children[i]); + } + + // translate element itself if necessary + translateElement(element); + } + + + /** + * Startup & Public API + * + * Warning: this part of the code contains browser-specific chunks -- + * that's where obsolete browsers, namely IE8 and earlier, are handled. + * + * Unlike the rest of the lib, this section is not shared with FirefoxOS/Gaia. + */ + + // browser-specific startup + if (document.addEventListener) { // modern browsers and IE9+ + document.addEventListener('DOMContentLoaded', function() { + var lang = document.documentElement.lang || navigator.language || navigator.userLanguage || 'en'; + loadLocale(lang, translateFragment); + }, false); + } else if (window.attachEvent) { // IE8 and before (= oldIE) + // TODO: check if jQuery is loaded (CSS selector + JSON + events) + + // dummy `console.log' and `console.warn' functions + if (!window.console) { + consoleLog = function(message) {}; // just ignore console.log calls + consoleWarn = function(message) { + if (gDEBUG) + alert('[l10n] ' + message); // vintage debugging, baby! + }; + } + + // worst hack ever for IE6 and IE7 + if (!window.JSON) { + consoleWarn('[l10n] no JSON support'); + + getL10nAttributes = function(element) { + if (!element) + return {}; + var l10nId = element.getAttribute('data-l10n-id'), + l10nArgs = element.getAttribute('data-l10n-args'), + args = {}; + if (l10nArgs) try { + args = eval(l10nArgs); // XXX yeah, I know... + } catch (e) { + consoleWarn('[l10n] could not parse arguments for #' + l10nId); + } + return { id: l10nId, args: args }; + }; + } + + // override `getTranslatableChildren' and `getL10nResourceLinks' + if (!document.querySelectorAll) { + consoleWarn('[l10n] no "querySelectorAll" support'); + + getTranslatableChildren = function(element) { + if (!element) + return []; + var nodes = element.getElementsByTagName('*'), + l10nElements = [], + n = nodes.length; + for (var i = 0; i < n; i++) { + if (nodes[i].getAttribute('data-l10n-id')) + l10nElements.push(nodes[i]); + } + return l10nElements; + }; + + getL10nResourceLinks = function() { + var links = document.getElementsByTagName('link'), + l10nLinks = [], + n = links.length; + for (var i = 0; i < n; i++) { + if (links[i].type == 'application/l10n') + l10nLinks.push(links[i]); + } + return l10nLinks; + }; + } + + // fire non-standard `localized' DOM events + if (document.createEventObject && !document.createEvent) { + fireL10nReadyEvent = function(lang) { + // hack to simulate a custom event in IE: + // to catch this event, add an event handler to `onpropertychange' + document.documentElement.localized = 1; + }; + } + + // startup for IE<9 + window.attachEvent('onload', function() { + gTextProp = document.body.textContent ? 'textContent' : 'innerText'; + var lang = document.documentElement.lang || navigator.language || navigator.userLanguage || 'en'; + loadLocale(lang, translateFragment); + }); + } + + // cross-browser API (sorry, oldIE doesn't support getters & setters) + return { + // get a localized string + get: function(key, args, fallback) { + var data = getL10nData(key, args) || fallback; + if (data) { // XXX double-check this + return 'textContent' in data ? data.textContent : ''; + } + return '{{' + key + '}}'; + }, + + // debug + getData: function() { return gL10nData; }, + getText: function() { return gTextData; }, + + // get|set the document language + getLanguage: function() { return gLanguage; }, + setLanguage: function(lang) { loadLocale(lang, translateFragment); }, + + // get the direction (ltr|rtl) of the current language + getDirection: function() { + // http://www.w3.org/International/questions/qa-scripts + // Arabic, Hebrew, Farsi, Pashto, Urdu + var rtlList = ['ar', 'he', 'fa', 'ps', 'ur']; + return (rtlList.indexOf(gLanguage) >= 0) ? 'rtl' : 'ltr'; + }, + + // translate an element or document fragment + translate: translateFragment, + + // this can be used to prevent race conditions + getReadyState: function() { return gReadyState; } + }; + +}) (window, document); + +// gettext-like shortcut for navigator.webL10n.get +if (window._ === undefined) + var _ = document.webL10n.get; + +// CommonJS +try { + exports = document.webL10n; +}catch(e){} \ No newline at end of file diff --git a/src/static/js/pad.js b/src/static/js/pad.js index c55f8dfe..b665c2fb 100644 --- a/src/static/js/pad.js +++ b/src/static/js/pad.js @@ -111,6 +111,7 @@ function getParams() var IsnoColors = params["noColors"]; var rtl = params["rtl"]; var alwaysShowChat = params["alwaysShowChat"]; + var lang = params["lang"]; if(IsnoColors) { @@ -173,6 +174,13 @@ function getParams() chat.stickToScreen(); } } + if(lang) + { + if(lang !== "") + { + document.webL10n.setLanguage(lang); + } + } } function getUrlVars() @@ -389,6 +397,10 @@ function handshake() }); // Bind the colorpicker var fb = $('#colorpicker').farbtastic({ callback: '#mycolorpickerpreview', width: 220}); + // Bind the read only button + $('#readonlyinput').on('click',function(){ + padeditbar.setEmbedLinks(); + }); } var pad = { @@ -447,6 +459,7 @@ var pad = { { pad.collabClient.sendClientMessage(msg); }, + createCookie: createCookie, init: function() { diff --git a/src/static/js/pad_editor.js b/src/static/js/pad_editor.js index 5a9e7b9b..690dde37 100644 --- a/src/static/js/pad_editor.js +++ b/src/static/js/pad_editor.js @@ -75,6 +75,11 @@ var padeditor = (function() { pad.changeViewOption('useMonospaceFont', $("#viewfontmenu").val() == 'monospace'); }); + $("#languagemenu").val(document.webL10n.getLanguage()); + $("#languagemenu").change(function() { + pad.createCookie("language",$("#languagemenu").val(),null,'/'); + document.webL10n.setLanguage($("#languagemenu").val()); + }); }, setViewOptions: function(newOptions) { diff --git a/src/templates/admin/settings.html b/src/templates/admin/settings.html index 880f0eb1..c4f50578 100644 --- a/src/templates/admin/settings.html +++ b/src/templates/admin/settings.html @@ -23,8 +23,8 @@ <h1>Etherpad Lite Settings</h1> - <a href='https://github.com/Pita/etherpad-lite/wiki/Example-Production-Settings.JSON'>Example production settings template</a> - <a href='https://github.com/Pita/etherpad-lite/wiki/Example-Development-Settings.JSON'>Example development settings template</a> + <a href='https://github.com/ether/etherpad-lite/wiki/Example-Production-Settings.JSON'>Example production settings template</a> + <a href='https://github.com/ether/etherpad-lite/wiki/Example-Development-Settings.JSON'>Example development settings template</a> <textarea class="settings"></textarea> <input type="button" class="settingsButton" id="saveSettings" value="Save Settings"> <input type="button" class="settingsButton" id="restartEtherpad" value="Restart Etherpad"> diff --git a/src/templates/index.html b/src/templates/index.html index 23c3c775..9fd33a26 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -31,8 +31,17 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"> - + <link rel="resource" type="application/l10n" href="locales.ini" /> <link rel="shortcut icon" href="<%=settings.favicon%>"> + + <script type="text/javascript"> + (function(document) { + // Set language for l10n + var language = document.cookie.match(/language=(\w{2})/); + if(language) document.documentElement.lang = language[1]; + })(document) + </script> + <script type="text/javascript" src="static/js/l10n.js" async></script> <style> html, body { @@ -148,8 +157,8 @@ <div id="wrapper"> <div id="inner"> - <div id="button" onclick="go2Random()" class="translate">New Pad</div> - <div id="label" class="translate">or create/open a Pad with the name</div> + <div id="button" onclick="go2Random()" data-l10n-id="index.newPad"></div> + <div id="label" data-l10n-id="index.createOpenPad"></div> <form action="#" onsubmit="go2Name();return false;"> <input type="text" id="padname" autofocus x-webkit-speech> <button type="submit">OK</button> @@ -187,5 +196,4 @@ // start the custom js if (typeof customStart == "function") customStart(); </script> - </html> diff --git a/src/templates/pad.html b/src/templates/pad.html index 6136d895..3f3eee4f 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -1,5 +1,6 @@ <% - var settings = require("ep_etherpad-lite/node/utils/Settings"); + var settings = require("ep_etherpad-lite/node/utils/Settings") + , langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs %> <!doctype html> <html> @@ -31,9 +32,18 @@ <meta charset="utf-8"> <meta name="robots" content="noindex, nofollow"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"> - <link rel="shortcut icon" href="<%=settings.favicon%>"> + <link rel="resource" type="application/l10n" href="../locales.ini" /> + <script type="text/javascript"> + (function() { + // Set language for l10n + var language = document.cookie.match(/language=(\w{2})/); + if(language) document.documentElement.lang = language[1]; + })(); + </script> + <script type="text/javascript" src="../static/js/l10n.js" async></script> + <% e.begin_block("styles"); %> <link href="../static/css/pad.css" rel="stylesheet"> <link href="../static/custom/pad.css" rel="stylesheet"> @@ -50,60 +60,60 @@ <ul class="menu_left"> <% e.begin_block("editbarMenuLeft"); %> <li class="acl-write" id="bold" data-key="bold"> - <a class="grouped-left" title="Bold (ctrl-B)"> + <a class="grouped-left" data-l10n-id="pad.toolbar.bold"> <span class="buttonicon buttonicon-bold"></span> </a> </li> <li class="acl-write" id="italic" data-key="italic"> - <a class="grouped-middle" title="Italics (ctrl-I)"> + <a class="grouped-middle" data-l10n-id="pad.toolbar.italic"> <span class="buttonicon buttonicon-italic"></span> </a> </li> <li class="acl-write" id="underline" data-key="underline"> - <a class="grouped-middle" title="Underline (ctrl-U)"> + <a class="grouped-middle" data-l10n-id="pad.toolbar.underline"> <span class="buttonicon buttonicon-underline"></span> </a> </li> <li class="acl-write" id="strikethrough" data-key="strikethrough"> - <a class="grouped-right" title="Strikethrough"> + <a class="grouped-right" data-l10n-id="pad.toolbar.strikethrough"> <span class="buttonicon buttonicon-strikethrough"></span> </a> </li> <li class="acl-write separator"></li> <li class="acl-write" id="oderedlist" data-key="insertorderedlist"> - <a class="grouped-left" title="Toggle Ordered List"> + <a class="grouped-left" data-l10n-id="pad.toolbar.ol"> <span class="buttonicon buttonicon-insertorderedlist"></span> </a> </li> <li class="acl-write" id="unoderedlist" data-key="insertunorderedlist"> - <a class="grouped-middle" title="Toggle Bullet List"> + <a class="grouped-middle" data-l10n-id="pad.toolbar.ul"> <span class="buttonicon buttonicon-insertunorderedlist"></span> </a> </li> <li class="acl-write" id="indent" data-key="indent"> - <a class="grouped-middle" title="Indent"> + <a class="grouped-middle" data-l10n-id="pad.toolbar.indent"> <span class="buttonicon buttonicon-indent"></span> </a> </li> <li class="acl-write" id="outdent" data-key="outdent"> - <a class="grouped-right" title="Unindent"> + <a class="grouped-right" data-l10n-id="pad.toolbar.unindent"> <span class="buttonicon buttonicon-outdent"></span> </a> </li> <li class="acl-write separator"></li> <li class="acl-write" id="undo" data-key="undo"> - <a class="grouped-left" title="Undo (ctrl-Z)"> + <a class="grouped-left" data-l10n-id="pad.toolbar.undo"> <span class="buttonicon buttonicon-undo"></span> </a> </li> <li class="acl-write" id="redo" data-key="redo"> - <a class="grouped-right" title="Redo (ctrl-Y)"> + <a class="grouped-right" data-l10n-id="pad.toolbar.redo"> <span class="buttonicon buttonicon-redo"></span> </a> </li> <li class="acl-write separator"></li> <li class="acl-write" id="clearAuthorship" data-key="clearauthorship"> - <a title="Clear Authorship Colors"> + <a data-l10n-id="pad.toolbar.clearAuthorship"> <span class="buttonicon buttonicon-clearauthorship"></span> </a> </li> @@ -112,34 +122,34 @@ <ul class="menu_right"> <% e.begin_block("editbarMenuRight"); %> <li data-key="import_export"> - <a class="grouped-left" id="importexportlink" title="Import/Export from/to different document formats"> + <a class="grouped-left" id="importexportlink" data-l10n-id="pad.toolbar.import_export"> <span class="buttonicon buttonicon-import_export"></span> </a> </li> <li onClick="document.location = document.location.pathname+ '/timeslider'"> - <a id="timesliderlink" class="grouped-middle" title="Show the history of this pad"> + <a id="timesliderlink" class="grouped-middle" data-l10n-id="pad.toolbar.timeslider"> <span class="buttonicon buttonicon-history"></span> </a> </li> <li class="acl-write" data-key="savedRevision"> - <a class="grouped-right" id="revisionlink" title="Mark this revision as a saved revision"> + <a class="grouped-right" id="revisionlink" data-l10n-id="pad.toolbar.savedRevision"> <span class="buttonicon buttonicon-savedRevision"></span> </a> </li> <li class="acl-write separator"></li> <li class="acl-write" data-key="settings"> - <a class="grouped-left" id="settingslink" title="Settings of this pad"> + <a class="grouped-left" id="settingslink" data-l10n-id="pad.toolbar.settings"> <span class="buttonicon buttonicon-settings"></span> </a> </li> <li data-key="embed"> - <a class="grouped-right" id="embedlink" title="Share and Embed this pad"> + <a class="grouped-right" id="embedlink" data-l10n-id="pad.toolbar.embed"> <span class="grouped-right buttonicon buttonicon-embed"></span> </a> </li> <li class="separator"></li> <li id="usericon" data-key="showusers"> - <a title="Show connected users"> + <a data-l10n-id="pad.toolbar.showusers"> <span class="buttonicon buttonicon-showusers"></span> <span id="online_count">1</span> </a> @@ -153,8 +163,8 @@ <div id="myuser"> <div id="mycolorpicker"> <div id="colorpicker"></div> - <button id="mycolorpickersave">Save</button> - <button id="mycolorpickercancel">Cancel</button> + <button id="mycolorpickersave" data-l10n-id="pad.colorpicker.save"></button> + <button id="mycolorpickercancel" data-l10n-id="pad.colorpicker.cancel"></button> <span id="mycolorpickerpreview" class="myswatchboxhoverable"></span> </div> <div id="myswatchbox"><div id="myswatch"></div></div> @@ -174,56 +184,76 @@ <div id="editorcontainerbox"> <div id="editorcontainer"></div> <div id="editorloadingbox"> - <p>Loading...</p> + <p data-l10n-id="pad.loading">Loading...</p> <noscript><strong>Sorry, you have to enable Javascript in order to use this.</strong></noscript> </div> </div> <div id="settings" class="popup"> - <h1>Pad settings</h1> + <h1 data-l10n-id="pad.settings.padSettings"></h1> <div class="column"> <% e.begin_block("mySettings"); %> - <h2>My view</h2> + <h2 data-l10n-id="pad.settings.myView"></h2> <p> <input type="checkbox" id="options-stickychat" onClick="chat.stickToScreen();"> - <label for="options-stickychat">Chat always on screen</label> + <label for="options-stickychat" data-l10n-id="pad.settings.stickychat"></label> </p> <p> <input type="checkbox" id="options-colorscheck"> - <label for="options-colorscheck">Authorship colors</label> + <label for="options-colorscheck" data-l10n-id="pad.settings.colorcheck"></label> </p> <p> <input type="checkbox" id="options-linenoscheck" checked> - <label for="options-linenoscheck">Line numbers</label> - </p> - <p> - Font type: - <select id="viewfontmenu"> - <option value="normal">Normal</option> - <option value="monospace">Monospaced</option> - </select> + <label for="options-linenoscheck" data-l10n-id="pad.settings.linenocheck"></label> </p> <% e.end_block(); %> + <table> + <% e.begin_block("mySettings.dropdowns"); %> + <tr> + <td> + <label for="viewfontmenu" data-l10n-id="pad.settings.fontType">Font type:</label> + </td> + <td> + <select id="viewfontmenu"> + <option value="normal" data-l10n-id="pad.settings.fontType.normal"></option> + <option value="monospace" data-l10n-id="pad.settings.fontType.monospaced"></option> + </select> + </td> + </tr> + <tr> + <td> + <label for="languagemenu" data-l10n-id="pad.settings.language">Language:</label> + </td> + <td> + <select id="languagemenu"> + <% for (lang in langs) { %> + <option value="<%=lang%>"><%=langs[lang].nativeName%></option> + <% } %> + </select> + </td> + </tr> + <% e.end_block(); %> + </table> </div> <div class="column"> <% e.begin_block("globalSettings"); %> - <h2>Global view</h2> + <h2 data-l10n-id="pad.settings.globalView"></h2> <% e.end_block(); %> </div> </div> <div id="importexport" class="popup"> - <h1>Import/Export</h1> + <h1 data-l10n-id="pad.importExport.import_export"></h1> <div class="column acl-write"> <% e.begin_block("importColumn"); %> - <h2>Upload any text file or document</h2><br> + <h2 data-l10n-id="pad.importExport.import"></h2><br> <form id="importform" method="post" action="" target="importiframe" enctype="multipart/form-data"> <div class="importformdiv" id="importformfilediv"> <input type="file" name="file" size="15" id="importfileinput"> <div class="importmessage" id="importmessagefail"></div> </div> <div id="import"></div> - <div class="importmessage" id="importmessagesuccess">Successful!</div> + <div class="importmessage" id="importmessagesuccess" data-l10n-id="pad.importExport.successful"></div> <div class="importformdiv" id="importformsubmitdiv"> <input type="hidden" name="padId" value="blpmaXT35R"> <span class="nowrap"> @@ -236,14 +266,14 @@ <% e.end_block(); %> </div> <div class="column"> - <h2>Export current pad as</h2> + <h2 data-l10n-id="pad.importExport.export"></h2> <% e.begin_block("exportColumn"); %> - <a id="exporthtmla" target="_blank" class="exportlink"><div class="exporttype" id="exporthtml">HTML</div></a> - <a id="exportplaina" target="_blank" class="exportlink"><div class="exporttype" id="exportplain">Plain text</div></a> - <a id="exportworda" target="_blank" class="exportlink"><div class="exporttype" id="exportword">Microsoft Word</div></a> - <a id="exportpdfa" target="_blank" class="exportlink"><div class="exporttype" id="exportpdf">PDF</div></a> - <a id="exportopena" target="_blank" class="exportlink"><div class="exporttype" id="exportopen">OpenDocument</div></a> - <a id="exportdokuwikia" target="_blank" class="exportlink"><div class="exporttype" id="exportdokuwiki">DokuWiki text</div></a> + <a id="exporthtmla" target="_blank" class="exportlink"><div class="exporttype" id="exporthtml" data-l10n-id="pad.importExport.exporthtml"></div></a> + <a id="exportplaina" target="_blank" class="exportlink"><div class="exporttype" id="exportplain" data-l10n-id="pad.importExport.exportplain"></div></a> + <a id="exportworda" target="_blank" class="exportlink"><div class="exporttype" id="exportword" data-l10n-id="pad.importExport.exportword"></div></a> + <a id="exportpdfa" target="_blank" class="exportlink"><div class="exporttype" id="exportpdf" data-l10n-id="pad.importExport.exportpdf"></div></a> + <a id="exportopena" target="_blank" class="exportlink"><div class="exporttype" id="exportopen" data-l10n-id="pad.importExport.exportopen"></div></a> + <a id="exportdokuwikia" target="_blank" class="exportlink"><div class="exporttype" id="exportdokuwiki" data-l10n-id="pad.importExport.exportdokuwiki"></div></a> <% e.end_block(); %> </div> </div> @@ -251,48 +281,48 @@ <div id="connectivity" class="popup"> <% e.begin_block("modals"); %> <div class="connected visible"> - <h2>Connected.</h2> + <h2 data-l10n-id="pad.modals.connected"></h2> </div> <div class="reconnecting"> - <h1>Reestablishing connection...</h1> + <h1 data-l10n-id="pad.modals.reconnecting"></h1> <p><img alt="" border="0" src="../static/img/connectingbar.gif" /></p> </div> <div class="userdup"> - <h1>Opened in another window.</h1> - <h2>You seem to have opened this pad in another browser window.</h2> - <p>If you'd like to use this window instead, you can reconnect.</p> - <button id="forcereconnect">Reconnect Now</button> + <h1 data-l10n-id="pad.modals.uderdup"></h1> + <h2 data-l10n-id="pad.modals.userdup.explanation"></h2> + <p data-l10n-id="pad.modals.connected.advice"></p> + <button id="forcereconnect" data-l10n-id="pad.modals.forcereconnect"></button> </div> <div class="unauth"> - <h1>No Authorization.</h1> - <p>Your browser's credentials or permissions have changed while viewing this pad. Try reconnecting.</p> - <button id="forcereconnect">Reconnect Now</button> + <h1 data-l10n-id="pad.modals.unauth"></h1> + <p data-l10n-id="pad.modals.unauth.explanation"></p> + <button id="forcereconnect" data-l10n-id="pad.modals.forcereconnect"></button> </div> <div class="looping"> - <h1>Disconnected.</h1> - <h2>We're having trouble talking to the EtherPad lite synchronization server.</h2> - <p>You may be connecting through an incompatible firewall or proxy server.</p> + <h1 data-l10n-id="pad.modals.looping"></h1> + <h2 data-l10n-id="pad.modals.looping.explanation"></h2> + <p data-l10n-id="pad.modals.looping.cause"></p> </div> <div class="initsocketfail"> - <h1>Disconnected.</h1> - <h2>We were unable to connect to the EtherPad lite synchronization server.</h2> - <p>This may be due to an incompatibility with your web browser or internet connection.</p> + <h1 data-l10n-id="pad.modals.initsocketfail"></h1> + <h2 data-l10n-id="pad.modals.initsocketfail.explanation"></h2> + <p data-l10n-id="pad.modals.initsocketfail.cause"></p> </div> <div class="slowcommit"> - <h1>Disconnected.</h1> - <h2>Server not responding.</h2> - <p>This may be due to network connectivity issues or high load on the server.</p> - <button id="forcereconnect">Reconnect Now</button> + <h1 data-l10n-id="pad.modals.slowcommit"></h1> + <h2 data-l10n-id="pad.modals.slowcommit.explanation"></h2> + <p data-l10n-id="pad.modals.slowcommit.cause"></p> + <button id="forcereconnect" data-l10n-id="pad.modals.forcereconnect"></button> </div> <div class="deleted"> - <h1>Disconnected.</h1> - <p>This pad was deleted.</p> + <h1 data-l10n-id="pad.modals.deleted"></h1> + <p data-l10n-id="pad.modals.deleted.explanation"></p> </div> <div class="disconnected"> - <h1>Disconnected.</h1> - <h2>Lost connection with the EtherPad lite synchronization server.</h2> - <p>This may be due to a loss of network connectivity. If this continues to happen, please let us know</p> - <button id="forcereconnect">Reconnect Now</button> + <h1 data-l10n-id="pad.modals.disconnected"></h1> + <h2 data-l10n-id="pad.modals.disconnected.explanation"></h2> + <p data-l10n-id="pad.modals.disconnected.cause"></p> + <button id="forcereconnect" data-l10n-id="pad.modals.forcereconnect"></button> </div> <form id="reconnectform" method="post" action="/ep/pad/reconnect" accept-charset="UTF-8" style="display: none;"> <input type="hidden" class="padId" name="padId"> @@ -305,17 +335,17 @@ <div id="embed" class="popup"> <% e.begin_block("embedPopup"); %> <div id="embedreadonly" class="right acl-write"> - <input type="checkbox" id="readonlyinput" onClick="padeditbar.setEmbedLinks();"> - <label for="readonlyinput">Read only</label> + <input type="checkbox" id="readonlyinput"> + <label for="readonlyinput" data-l10n-id="pad.share.readonly"></label> </div> - <h1>Share this pad</h1> + <h1 data-l10n-id="pad.share"></h1> <div id="linkcode"> - <h2>Link</h2> + <h2 data-l10n-id="pad.share.link"></h2> <input id="linkinput" type="text" value=""> </div> <br> <div id="embedcode"> - <h2>Embed URL</h2> + <h2 data-l10n-id="pad.share.emebdcode"></h2> <input id="embedinput" type="text" value=""> </div> <% e.end_block(); %> @@ -323,14 +353,14 @@ <div id="chatthrob"></div> - <div id="chaticon" title="Open the chat for this pad" onclick="chat.show();return false;"> - <span id="chatlabel">Chat</span> + <div id="chaticon" data-l10n-id="pad.chat" onclick="chat.show();return false;"> + <span id="chatlabel" data-l10n-id="pad.chat"></span> <span class="buttonicon buttonicon-chat"></span> <span id="chatcounter">0</span> </div> <div id="chatbox"> - <div id="titlebar"><span id ="titlelabel">Chat</span><a id="titlecross" onClick="chat.hide();return false;">- </a></div> + <div id="titlebar"><span id ="titlelabel" data-l10n-id="pad.chat"></span><a id="titlecross" onClick="chat.hide();return false;">- </a></div> <div id="chattext" class="authorColors"></div> <div id="chatinputbox"> <form> @@ -345,10 +375,9 @@ <% e.begin_block("scripts"); %> <script type="text/javascript"> - /* Display errors on page load to the user - (Gets overridden by padutils.setupGlobalExceptionHandler) - */ (function() { + // Display errors on page load to the user + // (Gets overridden by padutils.setupGlobalExceptionHandler) var originalHandler = window.onerror; window.onerror = function(msg, url, line) { var box = document.getElementById('editorloadingbox'); @@ -360,7 +389,7 @@ }; })(); </script> - + <script type="text/javascript" src="../static/js/require-kernel.js"></script> <script type="text/javascript" src="../socket.io/socket.io.js"></script> @@ -374,8 +403,6 @@ <script type="text/javascript"> var clientVars = {}; (function () { - - var pathComponents = location.pathname.split('/'); // Strip 'p' and the padname from the pathname and set as baseURL diff --git a/src/templates/timeslider.html b/src/templates/timeslider.html index 755699da..dfeee16c 100644 --- a/src/templates/timeslider.html +++ b/src/templates/timeslider.html @@ -1,9 +1,10 @@ <% - var settings = require("ep_etherpad-lite/node/utils/Settings"); + var settings = require("ep_etherpad-lite/node/utils/Settings") + , langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs %> <!doctype html> -<html lang="en"> -<title><%=settings.title%> Timeslider</title> +<html> +<title data-l10n-id="timeslider.pageTitle" data-l10n-args='{ "appTitle": "<%=settings.title%>" }'><%=settings.title%> Timeslider</title> <script> /* |@licstart The following is the entire license notice for the @@ -31,6 +32,16 @@ <meta charset="utf-8"> <meta name="robots" content="noindex, nofollow"> <link rel="shortcut icon" href="<%=settings.favicon%>"> + <link rel="resource" type="application/l10n" href="../../locales.ini" /> + + <script type="text/javascript"> + (function() { + // Set language for l10n + var language = document.cookie.match(/language=(\w{2})/); + if(language) document.documentElement.lang = language[1]; + })(); + </script> + <script type="text/javascript" src="../../static/js/l10n.js" async></script> <link rel="stylesheet" href="../../static/css/pad.css"> <link rel="stylesheet" href="../../static/css/timeslider.css"> <link rel="stylesheet" href="../../static/custom/timeslider.css"> @@ -69,12 +80,12 @@ <div class="editbarright toolbar" id="editbar"> <ul> <li onClick="window.padeditbar.toolbarClick('import_export');return false;"> - <a id="exportlink" title="Export to different document formats"> + <a id="exportlink" data-l10n-id="pad.importExport.export"> <div class="buttonicon buttonicon-import_export"></div> </a> </li> </ul> - <a id="returnbutton">Return to pad</a> + <a id="returnbutton" data-l10n-id="timeslider.toolbar.returnbutton"></a> </div> <div> @@ -82,9 +93,8 @@ <span id="revision_label"></span> <span id="revision_date"></span> </h1> - <p>Authors: - <span id="authorsList"> - <span>No Authors</span> + <p data-l10n-id="timeslider.toolbar.authors"> + <span id="authorsList" data-l10n-id="timeslider.toolbar.authorsList"></span> </span> </p> </div> </div> @@ -101,70 +111,69 @@ </div><!-- /padpage --> <div id="connectivity" class="popup"> - <% e.begin_block("modals"); %> +<% e.begin_block("modals"); %> <div class="connected visible"> - <h2>Connected.</h2> + <h2 data-l10n-id="pad.modals.connected"></h2> </div> <div class="reconnecting"> - <h1>Reestablishing connection...</h1> + <h1 data-l10n-id="pad.modals.reconnecting"></h1> <p><img alt="" border="0" src="../../static/img/connectingbar.gif" /></p> </div> <div class="userdup"> - <h1>Opened in another window.</h1> - <h2>You seem to have opened this pad in another browser window.</h2> - <p>If you'd like to use this window instead, you can reconnect.</p> - <button id="forcereconnect">Reconnect Now</button> + <h1 data-l10n-id="pad.modals.uderdup"></h1> + <h2 data-l10n-id="pad.modals.userdup.explanation"></h2> + <p data-l10n-id="pad.modals.connected.advice"></p> + <button id="forcereconnect" data-l10n-id="pad.modals.forcereconnect"></button> </div> <div class="unauth"> - <h1>No Authorization.</h1> - <p>Your browser's credentials or permissions have changed while viewing this pad. Try reconnecting.</p> - <button id="forcereconnect">Reconnect Now</button> + <h1 data-l10n-id="pad.modals.unauth"></h1> + <p data-l10n-id="pad.modals.unauth.explanation"></p> + <button id="forcereconnect" data-l10n-id="pad.modals.forcereconnect"></button> </div> <div class="looping"> - <h1>Disconnected.</h1> - <h2>We're having trouble talking to the EtherPad lite synchronization server.</h2> - <p>You may be connecting through an incompatible firewall or proxy server.</p> + <h1 data-l10n-id="pad.modals.looping"></h1> + <h2 data-l10n-id="pad.modals.looping.explanation"></h2> + <p data-l10n-id="pad.modals.looping.cause"></p> </div> <div class="initsocketfail"> - <h1>Disconnected.</h1> - <h2>We were unable to connect to the EtherPad lite synchronization server.</h2> - <p>This may be due to an incompatibility with your web browser or internet connection.</p> + <h1 data-l10n-id="pad.modals.initsocketfail"></h1> + <h2 data-l10n-id="pad.modals.initsocketfail.explanation"></h2> + <p data-l10n-id="pad.modals.initsocketfail.cause"></p> </div> <div class="slowcommit"> - <h1>Disconnected.</h1> - <h2>Server not responding.</h2> - <p>This may be due to network connectivity issues or high load on the server.</p> - <button id="forcereconnect">Reconnect Now</button> + <h1 data-l10n-id="pad.modals.slowcommit"></h1> + <h2 data-l10n-id="pad.modals.slowcommit.explanation"></h2> + <p data-l10n-id="pad.modals.slowcommit.cause"></p> + <button id="forcereconnect" data-l10n-id="pad.modals.forcereconnect"></button> </div> <div class="deleted"> - <h1>Disconnected.</h1> - <p>This pad was deleted.</p> + <h1 data-l10n-id="pad.modals.deleted"></h1> + <p data-l10n-id="pad.modals.deleted.explanation"></p> </div> <div class="disconnected"> - <h1>Disconnected.</h1> - <h2>Lost connection with the EtherPad lite synchronization server.</h2> - <p>This may be due to a loss of network connectivity. If this continues to happen, please let us know</p> - <button id="forcereconnect">Reconnect Now</button> + <h1 data-l10n-id="pad.modals.disconnected"></h1> + <h2 data-l10n-id="pad.modals.disconnected.explanation"></h2> + <p data-l10n-id="pad.modals.disconnected.cause"></p> + <button id="forcereconnect" data-l10n-id="pad.modals.forcereconnect"></button> </div> <form id="reconnectform" method="post" action="/ep/pad/reconnect" accept-charset="UTF-8" style="display: none;"> <input type="hidden" class="padId" name="padId"> <input type="hidden" class="diagnosticInfo" name="diagnosticInfo"> <input type="hidden" class="missedChanges" name="missedChanges"> </form> - <% e.end_block(); %> +<% e.end_block(); %> </div> <!-- export code --> <div id="importexport"> -<div id="export" class="popup"> - Export current version as: - <a id="exporthtmla" target="_blank" class="exportlink"><div class="exporttype" id="exporthtml">HTML</div></a> - <a id="exportplaina" target="_blank" class="exportlink"><div class="exporttype" id="exportplain">Plain text</div></a> - <a id="exportworda" target="_blank" class="exportlink"><div class="exporttype" id="exportword">Microsoft Word</div></a> - <a id="exportpdfa" target="_blank" class="exportlink"><div class="exporttype" id="exportpdf">PDF</div></a> - <a id="exportopena" target="_blank" class="exportlink"><div class="exporttype" id="exportopen">OpenDocument</div></a> - <a id="exportdokuwikia" target="_blank" class="exportlink"><div class="exporttype" id="exportdokuwiki">DokuWiki text</div></a> +<div id="export" class="popup" data-l10n-id="timeslider.exportCurrent"> + <a id="exporthtmla" target="_blank" class="exportlink"><div class="exporttype" id="exporthtml" data-l10n-id="pad.importExport.exporthtml"></div></a> + <a id="exportplaina" target="_blank" class="exportlink"><div class="exporttype" id="exportplain" data-l10n-id="pad.importExport.exportplain"></div></a> + <a id="exportworda" target="_blank" class="exportlink"><div class="exporttype" id="exportword" data-l10n-id="pad.importExport.exportword"></div></a> + <a id="exportpdfa" target="_blank" class="exportlink"><div class="exporttype" id="exportpdf" data-l10n-id="pad.importExport.exportpdf"></div></a> + <a id="exportopena" target="_blank" class="exportlink"><div class="exporttype" id="exportopen" data-l10n-id="pad.importExport.exportopen"></div></a> + <a id="exportdokuwikia" target="_blank" class="exportlink"><div class="exporttype" id="exportdokuwiki" data-l10n-id="pad.importExport.exportdokuwiki"></div></a> </div> </div> @@ -182,7 +191,6 @@ var clientVars = {}; (function () { - var pathComponents = location.pathname.split('/'); // Strip 'p', the padname and 'timeslider' from the pathname and set as baseURL diff --git a/tests/frontend/helper.js b/tests/frontend/helper.js new file mode 100644 index 00000000..ee57c869 --- /dev/null +++ b/tests/frontend/helper.js @@ -0,0 +1,159 @@ +var helper = {}; + +(function(){ + var $iframeContainer, $iframe, jsLibraries = {}; + + helper.init = function(cb){ + $iframeContainer = $("#iframe-container"); + + $.get('/static/js/jquery.js').done(function(code){ + // make sure we don't override existing jquery + jsLibraries["jquery"] = "if(typeof $ === 'undefined') {\n" + code + "\n}"; + + $.get('/tests/frontend/lib/sendkeys.js').done(function(code){ + jsLibraries["sendkeys"] = code; + + cb(); + }); + }); + } + + helper.randomString = function randomString(len) + { + var chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + var randomstring = ''; + for (var i = 0; i < len; i++) + { + var rnum = Math.floor(Math.random() * chars.length); + randomstring += chars.substring(rnum, rnum + 1); + } + return randomstring; + } + + var getFrameJQuery = function($iframe){ + /* + I tried over 9000 ways to inject javascript into iframes. + This is the only way I found that worked in IE 7+8+9, FF and Chrome + */ + + var win = $iframe[0].contentWindow; + var doc = win.document; + + //IE 8+9 Hack to make eval appear + //http://stackoverflow.com/questions/2720444/why-does-this-window-object-not-have-the-eval-function + win.execScript && win.execScript("null"); + + win.eval(jsLibraries["jquery"]); + win.eval(jsLibraries["sendkeys"]); + + win.$.window = win; + win.$.document = doc; + + return win.$; + } + + helper.clearCookies = function(){ + window.document.cookie = ""; + } + + helper.newPad = function(){ + //build opts object + var opts = {clearCookies: true} + if(typeof arguments[0] === 'function'){ + opts.cb = arguments[0] + } else { + opts = _.defaults(arguments[0], opts); + } + + //clear cookies + if(opts.clearCookies){ + helper.clearCookies(); + } + + var padName = "FRONTEND_TEST_" + helper.randomString(20); + $iframe = $("<iframe src='/p/" + padName + "'></iframe>"); + + //clean up inner iframe references + helper.padChrome$ = helper.padOuter$ = helper.padInner$ = null; + + //clean up iframes properly to prevent IE from memoryleaking + $iframeContainer.find("iframe").purgeFrame().done(function(){ + $iframeContainer.append($iframe); + $iframe.one('load', function(){ + helper.waitFor(function(){ + return !$iframe.contents().find("#editorloadingbox").is(":visible"); + }, 50000).done(function(){ + helper.padChrome$ = getFrameJQuery( $('#iframe-container iframe')); + helper.padOuter$ = getFrameJQuery(helper.padChrome$('iframe.[name="ace_outer"]')); + helper.padInner$ = getFrameJQuery( helper.padOuter$('iframe.[name="ace_inner"]')); + + //disable all animations, this makes tests faster and easier + helper.padChrome$.fx.off = true; + helper.padOuter$.fx.off = true; + helper.padInner$.fx.off = true; + + opts.cb(); + }).fail(function(){ + throw new Error("Pad never loaded"); + }); + }); + }); + + return padName; + } + + helper.waitFor = function(conditionFunc, _timeoutTime, _intervalTime){ + var timeoutTime = _timeoutTime || 1000; + var intervalTime = _intervalTime || 10; + + var deferred = $.Deferred(); + + var _fail = deferred.fail; + var listenForFail = false; + deferred.fail = function(){ + listenForFail = true; + _fail.apply(this, arguments); + } + + var intervalCheck = setInterval(function(){ + var passed = false; + + passed = conditionFunc(); + + if(passed){ + clearInterval(intervalCheck); + clearTimeout(timeout); + + deferred.resolve(); + } + }, intervalTime); + + var timeout = setTimeout(function(){ + clearInterval(intervalCheck); + var error = new Error("wait for condition never became true " + conditionFunc.toString()); + deferred.reject(error); + + if(!listenForFail){ + throw error; + } + }, timeoutTime); + + return deferred; + } + + /* Ensure console.log doesn't blow up in IE, ugly but ok for a test framework imho*/ + window.console = window.console || {}; + window.console.log = window.console.log || function(){} + + //force usage of callbacks in it + var _it = it; + it = function(name, func){ + if(func && func.length !== 1){ + func = function(){ + throw new Error("Please use always a callback with it() - " + func.toString()); + } + } + + _it(name, func); + } +})() \ No newline at end of file diff --git a/tests/frontend/index.html b/tests/frontend/index.html new file mode 100644 index 00000000..f89f419e --- /dev/null +++ b/tests/frontend/index.html @@ -0,0 +1,26 @@ +<!doctype html> +<html> + <title>Frontend tests</title> + <meta charset="utf-8"> + + <link rel="stylesheet" href="runner.css" /> + + <div id="console"></div> + <div id="mocha"></div> + <div id="iframe-container"></div> + + <script src="/static/js/jquery.js"></script> + <script src="lib/underscore.js"></script> + + <script src="lib/mocha.js"></script> + <script> mocha.setup('bdd') </script> + <script src="lib/expect.js"></script> + + <script src="lib/sendkeys.js"></script> + <script src="lib/jquery.iframe.js"></script> + <script src="helper.js"></script> + + <script src="specs_list.js"></script> + + <script src="runner.js"></script> +</html> diff --git a/tests/frontend/lib/expect.js b/tests/frontend/lib/expect.js new file mode 100644 index 00000000..ab5a1eea --- /dev/null +++ b/tests/frontend/lib/expect.js @@ -0,0 +1,1247 @@ + +(function (global, module) { + + if ('undefined' == typeof module) { + var module = { exports: {} } + , exports = module.exports + } + + /** + * Exports. + */ + + module.exports = expect; + expect.Assertion = Assertion; + + /** + * Exports version. + */ + + expect.version = '0.1.2'; + + /** + * Possible assertion flags. + */ + + var flags = { + not: ['to', 'be', 'have', 'include', 'only'] + , to: ['be', 'have', 'include', 'only', 'not'] + , only: ['have'] + , have: ['own'] + , be: ['an'] + }; + + function expect (obj) { + return new Assertion(obj); + } + + /** + * Constructor + * + * @api private + */ + + function Assertion (obj, flag, parent) { + this.obj = obj; + this.flags = {}; + + if (undefined != parent) { + this.flags[flag] = true; + + for (var i in parent.flags) { + if (parent.flags.hasOwnProperty(i)) { + this.flags[i] = true; + } + } + } + + var $flags = flag ? flags[flag] : keys(flags) + , self = this + + if ($flags) { + for (var i = 0, l = $flags.length; i < l; i++) { + // avoid recursion + if (this.flags[$flags[i]]) continue; + + var name = $flags[i] + , assertion = new Assertion(this.obj, name, this) + + if ('function' == typeof Assertion.prototype[name]) { + // clone the function, make sure we dont touch the prot reference + var old = this[name]; + this[name] = function () { + return old.apply(self, arguments); + } + + for (var fn in Assertion.prototype) { + if (Assertion.prototype.hasOwnProperty(fn) && fn != name) { + this[name][fn] = bind(assertion[fn], assertion); + } + } + } else { + this[name] = assertion; + } + } + } + }; + + /** + * Performs an assertion + * + * @api private + */ + + Assertion.prototype.assert = function (truth, msg, error) { + var msg = this.flags.not ? error : msg + , ok = this.flags.not ? !truth : truth; + + if (!ok) { + throw new Error(msg.call(this)); + } + + this.and = new Assertion(this.obj); + }; + + /** + * Check if the value is truthy + * + * @api public + */ + + Assertion.prototype.ok = function () { + this.assert( + !!this.obj + , function(){ return 'expected ' + i(this.obj) + ' to be truthy' } + , function(){ return 'expected ' + i(this.obj) + ' to be falsy' }); + }; + + /** + * Assert that the function throws. + * + * @param {Function|RegExp} callback, or regexp to match error string against + * @api public + */ + + Assertion.prototype.throwError = + Assertion.prototype.throwException = function (fn) { + expect(this.obj).to.be.a('function'); + + var thrown = false + , not = this.flags.not + + try { + this.obj(); + } catch (e) { + if ('function' == typeof fn) { + fn(e); + } else if ('object' == typeof fn) { + var subject = 'string' == typeof e ? e : e.message; + if (not) { + expect(subject).to.not.match(fn); + } else { + expect(subject).to.match(fn); + } + } + thrown = true; + } + + if ('object' == typeof fn && not) { + // in the presence of a matcher, ensure the `not` only applies to + // the matching. + this.flags.not = false; + } + + var name = this.obj.name || 'fn'; + this.assert( + thrown + , function(){ return 'expected ' + name + ' to throw an exception' } + , function(){ return 'expected ' + name + ' not to throw an exception' }); + }; + + /** + * Checks if the array is empty. + * + * @api public + */ + + Assertion.prototype.empty = function () { + var expectation; + + if ('object' == typeof this.obj && null !== this.obj && !isArray(this.obj)) { + if ('number' == typeof this.obj.length) { + expectation = !this.obj.length; + } else { + expectation = !keys(this.obj).length; + } + } else { + if ('string' != typeof this.obj) { + expect(this.obj).to.be.an('object'); + } + + expect(this.obj).to.have.property('length'); + expectation = !this.obj.length; + } + + this.assert( + expectation + , function(){ return 'expected ' + i(this.obj) + ' to be empty' } + , function(){ return 'expected ' + i(this.obj) + ' to not be empty' }); + return this; + }; + + /** + * Checks if the obj exactly equals another. + * + * @api public + */ + + Assertion.prototype.be = + Assertion.prototype.equal = function (obj) { + this.assert( + obj === this.obj + , function(){ return 'expected ' + i(this.obj) + ' to equal ' + i(obj) } + , function(){ return 'expected ' + i(this.obj) + ' to not equal ' + i(obj) }); + return this; + }; + + /** + * Checks if the obj sortof equals another. + * + * @api public + */ + + Assertion.prototype.eql = function (obj) { + this.assert( + expect.eql(obj, this.obj) + , function(){ return 'expected ' + i(this.obj) + ' to sort of equal ' + i(obj) } + , function(){ return 'expected ' + i(this.obj) + ' to sort of not equal ' + i(obj) }); + return this; + }; + + /** + * Assert within start to finish (inclusive). + * + * @param {Number} start + * @param {Number} finish + * @api public + */ + + Assertion.prototype.within = function (start, finish) { + var range = start + '..' + finish; + this.assert( + this.obj >= start && this.obj <= finish + , function(){ return 'expected ' + i(this.obj) + ' to be within ' + range } + , function(){ return 'expected ' + i(this.obj) + ' to not be within ' + range }); + return this; + }; + + /** + * Assert typeof / instance of + * + * @api public + */ + + Assertion.prototype.a = + Assertion.prototype.an = function (type) { + if ('string' == typeof type) { + // proper english in error msg + var n = /^[aeiou]/.test(type) ? 'n' : ''; + + // typeof with support for 'array' + this.assert( + 'array' == type ? isArray(this.obj) : + 'object' == type + ? 'object' == typeof this.obj && null !== this.obj + : type == typeof this.obj + , function(){ return 'expected ' + i(this.obj) + ' to be a' + n + ' ' + type } + , function(){ return 'expected ' + i(this.obj) + ' not to be a' + n + ' ' + type }); + } else { + // instanceof + var name = type.name || 'supplied constructor'; + this.assert( + this.obj instanceof type + , function(){ return 'expected ' + i(this.obj) + ' to be an instance of ' + name } + , function(){ return 'expected ' + i(this.obj) + ' not to be an instance of ' + name }); + } + + return this; + }; + + /** + * Assert numeric value above _n_. + * + * @param {Number} n + * @api public + */ + + Assertion.prototype.greaterThan = + Assertion.prototype.above = function (n) { + this.assert( + this.obj > n + , function(){ return 'expected ' + i(this.obj) + ' to be above ' + n } + , function(){ return 'expected ' + i(this.obj) + ' to be below ' + n }); + return this; + }; + + /** + * Assert numeric value below _n_. + * + * @param {Number} n + * @api public + */ + + Assertion.prototype.lessThan = + Assertion.prototype.below = function (n) { + this.assert( + this.obj < n + , function(){ return 'expected ' + i(this.obj) + ' to be below ' + n } + , function(){ return 'expected ' + i(this.obj) + ' to be above ' + n }); + return this; + }; + + /** + * Assert string value matches _regexp_. + * + * @param {RegExp} regexp + * @api public + */ + + Assertion.prototype.match = function (regexp) { + this.assert( + regexp.exec(this.obj) + , function(){ return 'expected ' + i(this.obj) + ' to match ' + regexp } + , function(){ return 'expected ' + i(this.obj) + ' not to match ' + regexp }); + return this; + }; + + /** + * Assert property "length" exists and has value of _n_. + * + * @param {Number} n + * @api public + */ + + Assertion.prototype.length = function (n) { + expect(this.obj).to.have.property('length'); + var len = this.obj.length; + this.assert( + n == len + , function(){ return 'expected ' + i(this.obj) + ' to have a length of ' + n + ' but got ' + len } + , function(){ return 'expected ' + i(this.obj) + ' to not have a length of ' + len }); + return this; + }; + + /** + * Assert property _name_ exists, with optional _val_. + * + * @param {String} name + * @param {Mixed} val + * @api public + */ + + Assertion.prototype.property = function (name, val) { + if (this.flags.own) { + this.assert( + Object.prototype.hasOwnProperty.call(this.obj, name) + , function(){ return 'expected ' + i(this.obj) + ' to have own property ' + i(name) } + , function(){ return 'expected ' + i(this.obj) + ' to not have own property ' + i(name) }); + return this; + } + + if (this.flags.not && undefined !== val) { + if (undefined === this.obj[name]) { + throw new Error(i(this.obj) + ' has no property ' + i(name)); + } + } else { + var hasProp; + try { + hasProp = name in this.obj + } catch (e) { + hasProp = undefined !== this.obj[name] + } + + this.assert( + hasProp + , function(){ return 'expected ' + i(this.obj) + ' to have a property ' + i(name) } + , function(){ return 'expected ' + i(this.obj) + ' to not have a property ' + i(name) }); + } + + if (undefined !== val) { + this.assert( + val === this.obj[name] + , function(){ return 'expected ' + i(this.obj) + ' to have a property ' + i(name) + + ' of ' + i(val) + ', but got ' + i(this.obj[name]) } + , function(){ return 'expected ' + i(this.obj) + ' to not have a property ' + i(name) + + ' of ' + i(val) }); + } + + this.obj = this.obj[name]; + return this; + }; + + /** + * Assert that the array contains _obj_ or string contains _obj_. + * + * @param {Mixed} obj|string + * @api public + */ + + Assertion.prototype.string = + Assertion.prototype.contain = function (obj) { + if ('string' == typeof this.obj) { + this.assert( + ~this.obj.indexOf(obj) + , function(){ return 'expected ' + i(this.obj) + ' to contain ' + i(obj) } + , function(){ return 'expected ' + i(this.obj) + ' to not contain ' + i(obj) }); + } else { + this.assert( + ~indexOf(this.obj, obj) + , function(){ return 'expected ' + i(this.obj) + ' to contain ' + i(obj) } + , function(){ return 'expected ' + i(this.obj) + ' to not contain ' + i(obj) }); + } + return this; + }; + + /** + * Assert exact keys or inclusion of keys by using + * the `.own` modifier. + * + * @param {Array|String ...} keys + * @api public + */ + + Assertion.prototype.key = + Assertion.prototype.keys = function ($keys) { + var str + , ok = true; + + $keys = isArray($keys) + ? $keys + : Array.prototype.slice.call(arguments); + + if (!$keys.length) throw new Error('keys required'); + + var actual = keys(this.obj) + , len = $keys.length; + + // Inclusion + ok = every($keys, function (key) { + return ~indexOf(actual, key); + }); + + // Strict + if (!this.flags.not && this.flags.only) { + ok = ok && $keys.length == actual.length; + } + + // Key string + if (len > 1) { + $keys = map($keys, function (key) { + return i(key); + }); + var last = $keys.pop(); + str = $keys.join(', ') + ', and ' + last; + } else { + str = i($keys[0]); + } + + // Form + str = (len > 1 ? 'keys ' : 'key ') + str; + + // Have / include + str = (!this.flags.only ? 'include ' : 'only have ') + str; + + // Assertion + this.assert( + ok + , function(){ return 'expected ' + i(this.obj) + ' to ' + str } + , function(){ return 'expected ' + i(this.obj) + ' to not ' + str }); + + return this; + }; + /** + * Assert a failure. + * + * @param {String ...} custom message + * @api public + */ + Assertion.prototype.fail = function (msg) { + msg = msg || "explicit failure"; + this.assert(false, msg, msg); + return this; + }; + + /** + * Function bind implementation. + */ + + function bind (fn, scope) { + return function () { + return fn.apply(scope, arguments); + } + } + + /** + * Array every compatibility + * + * @see bit.ly/5Fq1N2 + * @api public + */ + + function every (arr, fn, thisObj) { + var scope = thisObj || global; + for (var i = 0, j = arr.length; i < j; ++i) { + if (!fn.call(scope, arr[i], i, arr)) { + return false; + } + } + return true; + }; + + /** + * Array indexOf compatibility. + * + * @see bit.ly/a5Dxa2 + * @api public + */ + + function indexOf (arr, o, i) { + if (Array.prototype.indexOf) { + return Array.prototype.indexOf.call(arr, o, i); + } + + if (arr.length === undefined) { + return -1; + } + + for (var j = arr.length, i = i < 0 ? i + j < 0 ? 0 : i + j : i || 0 + ; i < j && arr[i] !== o; i++); + + return j <= i ? -1 : i; + }; + + // https://gist.github.com/1044128/ + var getOuterHTML = function(element) { + if ('outerHTML' in element) return element.outerHTML; + var ns = "http://www.w3.org/1999/xhtml"; + var container = document.createElementNS(ns, '_'); + var elemProto = (window.HTMLElement || window.Element).prototype; + var xmlSerializer = new XMLSerializer(); + var html; + if (document.xmlVersion) { + return xmlSerializer.serializeToString(element); + } else { + container.appendChild(element.cloneNode(false)); + html = container.innerHTML.replace('><', '>' + element.innerHTML + '<'); + container.innerHTML = ''; + return html; + } + }; + + // Returns true if object is a DOM element. + var isDOMElement = function (object) { + if (typeof HTMLElement === 'object') { + return object instanceof HTMLElement; + } else { + return object && + typeof object === 'object' && + object.nodeType === 1 && + typeof object.nodeName === 'string'; + } + }; + + /** + * Inspects an object. + * + * @see taken from node.js `util` module (copyright Joyent, MIT license) + * @api private + */ + + function i (obj, showHidden, depth) { + var seen = []; + + function stylize (str) { + return str; + }; + + function format (value, recurseTimes) { + // Provide a hook for user-specified inspect functions. + // Check that value is an object with an inspect function on it + if (value && typeof value.inspect === 'function' && + // Filter out the util module, it's inspect function is special + value !== exports && + // Also filter out any prototype objects using the circular check. + !(value.constructor && value.constructor.prototype === value)) { + return value.inspect(recurseTimes); + } + + // Primitive types cannot have properties + switch (typeof value) { + case 'undefined': + return stylize('undefined', 'undefined'); + + case 'string': + var simple = '\'' + json.stringify(value).replace(/^"|"$/g, '') + .replace(/'/g, "\\'") + .replace(/\\"/g, '"') + '\''; + return stylize(simple, 'string'); + + case 'number': + return stylize('' + value, 'number'); + + case 'boolean': + return stylize('' + value, 'boolean'); + } + // For some reason typeof null is "object", so special case here. + if (value === null) { + return stylize('null', 'null'); + } + + if (isDOMElement(value)) { + return getOuterHTML(value); + } + + // Look up the keys of the object. + var visible_keys = keys(value); + var $keys = showHidden ? Object.getOwnPropertyNames(value) : visible_keys; + + // Functions without properties can be shortcutted. + if (typeof value === 'function' && $keys.length === 0) { + if (isRegExp(value)) { + return stylize('' + value, 'regexp'); + } else { + var name = value.name ? ': ' + value.name : ''; + return stylize('[Function' + name + ']', 'special'); + } + } + + // Dates without properties can be shortcutted + if (isDate(value) && $keys.length === 0) { + return stylize(value.toUTCString(), 'date'); + } + + var base, type, braces; + // Determine the object type + if (isArray(value)) { + type = 'Array'; + braces = ['[', ']']; + } else { + type = 'Object'; + braces = ['{', '}']; + } + + // Make functions say that they are functions + if (typeof value === 'function') { + var n = value.name ? ': ' + value.name : ''; + base = (isRegExp(value)) ? ' ' + value : ' [Function' + n + ']'; + } else { + base = ''; + } + + // Make dates with properties first say the date + if (isDate(value)) { + base = ' ' + value.toUTCString(); + } + + if ($keys.length === 0) { + return braces[0] + base + braces[1]; + } + + if (recurseTimes < 0) { + if (isRegExp(value)) { + return stylize('' + value, 'regexp'); + } else { + return stylize('[Object]', 'special'); + } + } + + seen.push(value); + + var output = map($keys, function (key) { + var name, str; + if (value.__lookupGetter__) { + if (value.__lookupGetter__(key)) { + if (value.__lookupSetter__(key)) { + str = stylize('[Getter/Setter]', 'special'); + } else { + str = stylize('[Getter]', 'special'); + } + } else { + if (value.__lookupSetter__(key)) { + str = stylize('[Setter]', 'special'); + } + } + } + if (indexOf(visible_keys, key) < 0) { + name = '[' + key + ']'; + } + if (!str) { + if (indexOf(seen, value[key]) < 0) { + if (recurseTimes === null) { + str = format(value[key]); + } else { + str = format(value[key], recurseTimes - 1); + } + if (str.indexOf('\n') > -1) { + if (isArray(value)) { + str = map(str.split('\n'), function (line) { + return ' ' + line; + }).join('\n').substr(2); + } else { + str = '\n' + map(str.split('\n'), function (line) { + return ' ' + line; + }).join('\n'); + } + } + } else { + str = stylize('[Circular]', 'special'); + } + } + if (typeof name === 'undefined') { + if (type === 'Array' && key.match(/^\d+$/)) { + return str; + } + name = json.stringify('' + key); + if (name.match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)) { + name = name.substr(1, name.length - 2); + name = stylize(name, 'name'); + } else { + name = name.replace(/'/g, "\\'") + .replace(/\\"/g, '"') + .replace(/(^"|"$)/g, "'"); + name = stylize(name, 'string'); + } + } + + return name + ': ' + str; + }); + + seen.pop(); + + var numLinesEst = 0; + var length = reduce(output, function (prev, cur) { + numLinesEst++; + if (indexOf(cur, '\n') >= 0) numLinesEst++; + return prev + cur.length + 1; + }, 0); + + if (length > 50) { + output = braces[0] + + (base === '' ? '' : base + '\n ') + + ' ' + + output.join(',\n ') + + ' ' + + braces[1]; + + } else { + output = braces[0] + base + ' ' + output.join(', ') + ' ' + braces[1]; + } + + return output; + } + return format(obj, (typeof depth === 'undefined' ? 2 : depth)); + }; + + function isArray (ar) { + return Object.prototype.toString.call(ar) == '[object Array]'; + }; + + function isRegExp(re) { + var s = '' + re; + return re instanceof RegExp || // easy case + // duck-type for context-switching evalcx case + typeof(re) === 'function' && + re.constructor.name === 'RegExp' && + re.compile && + re.test && + re.exec && + s.match(/^\/.*\/[gim]{0,3}$/); + }; + + function isDate(d) { + if (d instanceof Date) return true; + return false; + }; + + function keys (obj) { + if (Object.keys) { + return Object.keys(obj); + } + + var keys = []; + + for (var i in obj) { + if (Object.prototype.hasOwnProperty.call(obj, i)) { + keys.push(i); + } + } + + return keys; + } + + function map (arr, mapper, that) { + if (Array.prototype.map) { + return Array.prototype.map.call(arr, mapper, that); + } + + var other= new Array(arr.length); + + for (var i= 0, n = arr.length; i<n; i++) + if (i in arr) + other[i] = mapper.call(that, arr[i], i, arr); + + return other; + }; + + function reduce (arr, fun) { + if (Array.prototype.reduce) { + return Array.prototype.reduce.apply( + arr + , Array.prototype.slice.call(arguments, 1) + ); + } + + var len = +this.length; + + if (typeof fun !== "function") + throw new TypeError(); + + // no value to return if no initial value and an empty array + if (len === 0 && arguments.length === 1) + throw new TypeError(); + + var i = 0; + if (arguments.length >= 2) { + var rv = arguments[1]; + } else { + do { + if (i in this) { + rv = this[i++]; + break; + } + + // if array contains no values, no initial value to return + if (++i >= len) + throw new TypeError(); + } while (true); + } + + for (; i < len; i++) { + if (i in this) + rv = fun.call(null, rv, this[i], i, this); + } + + return rv; + }; + + /** + * Asserts deep equality + * + * @see taken from node.js `assert` module (copyright Joyent, MIT license) + * @api private + */ + + expect.eql = function eql (actual, expected) { + // 7.1. All identical values are equivalent, as determined by ===. + if (actual === expected) { + return true; + } else if ('undefined' != typeof Buffer + && Buffer.isBuffer(actual) && Buffer.isBuffer(expected)) { + if (actual.length != expected.length) return false; + + for (var i = 0; i < actual.length; i++) { + if (actual[i] !== expected[i]) return false; + } + + return true; + + // 7.2. If the expected value is a Date object, the actual value is + // equivalent if it is also a Date object that refers to the same time. + } else if (actual instanceof Date && expected instanceof Date) { + return actual.getTime() === expected.getTime(); + + // 7.3. Other pairs that do not both pass typeof value == "object", + // equivalence is determined by ==. + } else if (typeof actual != 'object' && typeof expected != 'object') { + return actual == expected; + + // 7.4. For all other Object pairs, including Array objects, equivalence is + // determined by having the same number of owned properties (as verified + // with Object.prototype.hasOwnProperty.call), the same set of keys + // (although not necessarily the same order), equivalent values for every + // corresponding key, and an identical "prototype" property. Note: this + // accounts for both named and indexed properties on Arrays. + } else { + return objEquiv(actual, expected); + } + } + + function isUndefinedOrNull (value) { + return value === null || value === undefined; + } + + function isArguments (object) { + return Object.prototype.toString.call(object) == '[object Arguments]'; + } + + function objEquiv (a, b) { + if (isUndefinedOrNull(a) || isUndefinedOrNull(b)) + return false; + // an identical "prototype" property. + if (a.prototype !== b.prototype) return false; + //~~~I've managed to break Object.keys through screwy arguments passing. + // Converting to array solves the problem. + if (isArguments(a)) { + if (!isArguments(b)) { + return false; + } + a = pSlice.call(a); + b = pSlice.call(b); + return expect.eql(a, b); + } + try{ + var ka = keys(a), + kb = keys(b), + key, i; + } catch (e) {//happens when one is a string literal and the other isn't + return false; + } + // having the same number of owned properties (keys incorporates hasOwnProperty) + if (ka.length != kb.length) + return false; + //the same set of keys (although not necessarily the same order), + ka.sort(); + kb.sort(); + //~~~cheap key test + for (i = ka.length - 1; i >= 0; i--) { + if (ka[i] != kb[i]) + return false; + } + //equivalent values for every corresponding key, and + //~~~possibly expensive deep test + for (i = ka.length - 1; i >= 0; i--) { + key = ka[i]; + if (!expect.eql(a[key], b[key])) + return false; + } + return true; + } + + var json = (function () { + "use strict"; + + if ('object' == typeof JSON && JSON.parse && JSON.stringify) { + return { + parse: nativeJSON.parse + , stringify: nativeJSON.stringify + } + } + + var JSON = {}; + + function f(n) { + // Format integers to have at least two digits. + return n < 10 ? '0' + n : n; + } + + function date(d, key) { + return isFinite(d.valueOf()) ? + d.getUTCFullYear() + '-' + + f(d.getUTCMonth() + 1) + '-' + + f(d.getUTCDate()) + 'T' + + f(d.getUTCHours()) + ':' + + f(d.getUTCMinutes()) + ':' + + f(d.getUTCSeconds()) + 'Z' : null; + }; + + var cx = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, + escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, + gap, + indent, + meta = { // table of character substitutions + '\b': '\\b', + '\t': '\\t', + '\n': '\\n', + '\f': '\\f', + '\r': '\\r', + '"' : '\\"', + '\\': '\\\\' + }, + rep; + + + function quote(string) { + + // If the string contains no control characters, no quote characters, and no + // backslash characters, then we can safely slap some quotes around it. + // Otherwise we must also replace the offending characters with safe escape + // sequences. + + escapable.lastIndex = 0; + return escapable.test(string) ? '"' + string.replace(escapable, function (a) { + var c = meta[a]; + return typeof c === 'string' ? c : + '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); + }) + '"' : '"' + string + '"'; + } + + + function str(key, holder) { + + // Produce a string from holder[key]. + + var i, // The loop counter. + k, // The member key. + v, // The member value. + length, + mind = gap, + partial, + value = holder[key]; + + // If the value has a toJSON method, call it to obtain a replacement value. + + if (value instanceof Date) { + value = date(key); + } + + // If we were called with a replacer function, then call the replacer to + // obtain a replacement value. + + if (typeof rep === 'function') { + value = rep.call(holder, key, value); + } + + // What happens next depends on the value's type. + + switch (typeof value) { + case 'string': + return quote(value); + + case 'number': + + // JSON numbers must be finite. Encode non-finite numbers as null. + + return isFinite(value) ? String(value) : 'null'; + + case 'boolean': + case 'null': + + // If the value is a boolean or null, convert it to a string. Note: + // typeof null does not produce 'null'. The case is included here in + // the remote chance that this gets fixed someday. + + return String(value); + + // If the type is 'object', we might be dealing with an object or an array or + // null. + + case 'object': + + // Due to a specification blunder in ECMAScript, typeof null is 'object', + // so watch out for that case. + + if (!value) { + return 'null'; + } + + // Make an array to hold the partial results of stringifying this object value. + + gap += indent; + partial = []; + + // Is the value an array? + + if (Object.prototype.toString.apply(value) === '[object Array]') { + + // The value is an array. Stringify every element. Use null as a placeholder + // for non-JSON values. + + length = value.length; + for (i = 0; i < length; i += 1) { + partial[i] = str(i, value) || 'null'; + } + + // Join all of the elements together, separated with commas, and wrap them in + // brackets. + + v = partial.length === 0 ? '[]' : gap ? + '[\n' + gap + partial.join(',\n' + gap) + '\n' + mind + ']' : + '[' + partial.join(',') + ']'; + gap = mind; + return v; + } + + // If the replacer is an array, use it to select the members to be stringified. + + if (rep && typeof rep === 'object') { + length = rep.length; + for (i = 0; i < length; i += 1) { + if (typeof rep[i] === 'string') { + k = rep[i]; + v = str(k, value); + if (v) { + partial.push(quote(k) + (gap ? ': ' : ':') + v); + } + } + } + } else { + + // Otherwise, iterate through all of the keys in the object. + + for (k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + v = str(k, value); + if (v) { + partial.push(quote(k) + (gap ? ': ' : ':') + v); + } + } + } + } + + // Join all of the member texts together, separated with commas, + // and wrap them in braces. + + v = partial.length === 0 ? '{}' : gap ? + '{\n' + gap + partial.join(',\n' + gap) + '\n' + mind + '}' : + '{' + partial.join(',') + '}'; + gap = mind; + return v; + } + } + + // If the JSON object does not yet have a stringify method, give it one. + + JSON.stringify = function (value, replacer, space) { + + // The stringify method takes a value and an optional replacer, and an optional + // space parameter, and returns a JSON text. The replacer can be a function + // that can replace values, or an array of strings that will select the keys. + // A default replacer method can be provided. Use of the space parameter can + // produce text that is more easily readable. + + var i; + gap = ''; + indent = ''; + + // If the space parameter is a number, make an indent string containing that + // many spaces. + + if (typeof space === 'number') { + for (i = 0; i < space; i += 1) { + indent += ' '; + } + + // If the space parameter is a string, it will be used as the indent string. + + } else if (typeof space === 'string') { + indent = space; + } + + // If there is a replacer, it must be a function or an array. + // Otherwise, throw an error. + + rep = replacer; + if (replacer && typeof replacer !== 'function' && + (typeof replacer !== 'object' || + typeof replacer.length !== 'number')) { + throw new Error('JSON.stringify'); + } + + // Make a fake root object containing our value under the key of ''. + // Return the result of stringifying the value. + + return str('', {'': value}); + }; + + // If the JSON object does not yet have a parse method, give it one. + + JSON.parse = function (text, reviver) { + // The parse method takes a text and an optional reviver function, and returns + // a JavaScript value if the text is a valid JSON text. + + var j; + + function walk(holder, key) { + + // The walk method is used to recursively walk the resulting structure so + // that modifications can be made. + + var k, v, value = holder[key]; + if (value && typeof value === 'object') { + for (k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + v = walk(value, k); + if (v !== undefined) { + value[k] = v; + } else { + delete value[k]; + } + } + } + } + return reviver.call(holder, key, value); + } + + + // Parsing happens in four stages. In the first stage, we replace certain + // Unicode characters with escape sequences. JavaScript handles many characters + // incorrectly, either silently deleting them, or treating them as line endings. + + text = String(text); + cx.lastIndex = 0; + if (cx.test(text)) { + text = text.replace(cx, function (a) { + return '\\u' + + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); + }); + } + + // In the second stage, we run the text against regular expressions that look + // for non-JSON patterns. We are especially concerned with '()' and 'new' + // because they can cause invocation, and '=' because it can cause mutation. + // But just to be safe, we want to reject all unexpected forms. + + // We split the second stage into 4 regexp operations in order to work around + // crippling inefficiencies in IE's and Safari's regexp engines. First we + // replace the JSON backslash pairs with '@' (a non-JSON character). Second, we + // replace all simple value tokens with ']' characters. Third, we delete all + // open brackets that follow a colon or comma or that begin the text. Finally, + // we look to see that the remaining characters are only whitespace or ']' or + // ',' or ':' or '{' or '}'. If that is so, then the text is safe for eval. + + if (/^[\],:{}\s]*$/ + .test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, '@') + .replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']') + .replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) { + + // In the third stage we use the eval function to compile the text into a + // JavaScript structure. The '{' operator is subject to a syntactic ambiguity + // in JavaScript: it can begin a block or an object literal. We wrap the text + // in parens to eliminate the ambiguity. + + j = eval('(' + text + ')'); + + // In the optional fourth stage, we recursively walk the new structure, passing + // each name/value pair to a reviver function for possible transformation. + + return typeof reviver === 'function' ? + walk({'': j}, '') : j; + } + + // If the text is not JSON parseable, then a SyntaxError is thrown. + + throw new SyntaxError('JSON.parse'); + }; + + return JSON; + })(); + + if ('undefined' != typeof window) { + window.expect = module.exports; + } + +})( + this + , 'undefined' != typeof module ? module : {} + , 'undefined' != typeof exports ? exports : {} +); diff --git a/tests/frontend/lib/jquery.iframe.js b/tests/frontend/lib/jquery.iframe.js new file mode 100644 index 00000000..3c3b7b05 --- /dev/null +++ b/tests/frontend/lib/jquery.iframe.js @@ -0,0 +1,39 @@ +//copied from http://stackoverflow.com/questions/8407946/is-it-possible-to-use-iframes-in-ie-without-memory-leaks +(function($) { + $.fn.purgeFrame = function() { + var deferred; + + if ($.browser.msie && parseFloat($.browser.version, 10) < 9) { + deferred = purge(this); + } else { + this.remove(); + deferred = $.Deferred(); + deferred.resolve(); + } + + return deferred; + }; + + function purge($frame) { + var sem = $frame.length + , deferred = $.Deferred(); + + $frame.load(function() { + var frame = this; + frame.contentWindow.document.innerHTML = ''; + + sem -= 1; + if (sem <= 0) { + $frame.remove(); + deferred.resolve(); + } + }); + $frame.attr('src', 'about:blank'); + + if ($frame.length === 0) { + deferred.resolve(); + } + + return deferred.promise(); + } +})(jQuery); \ No newline at end of file diff --git a/tests/frontend/lib/mocha.js b/tests/frontend/lib/mocha.js new file mode 100644 index 00000000..5f2da013 --- /dev/null +++ b/tests/frontend/lib/mocha.js @@ -0,0 +1,4868 @@ +;(function(){ + + +// CommonJS require() + +function require(p){ + var path = require.resolve(p) + , mod = require.modules[path]; + if (!mod) throw new Error('failed to require "' + p + '"'); + if (!mod.exports) { + mod.exports = {}; + mod.call(mod.exports, mod, mod.exports, require.relative(path)); + } + return mod.exports; + } + +require.modules = {}; + +require.resolve = function (path){ + var orig = path + , reg = path + '.js' + , index = path + '/index.js'; + return require.modules[reg] && reg + || require.modules[index] && index + || orig; + }; + +require.register = function (path, fn){ + require.modules[path] = fn; + }; + +require.relative = function (parent) { + return function(p){ + if ('.' != p.charAt(0)) return require(p); + + var path = parent.split('/') + , segs = p.split('/'); + path.pop(); + + for (var i = 0; i < segs.length; i++) { + var seg = segs[i]; + if ('..' == seg) path.pop(); + else if ('.' != seg) path.push(seg); + } + + return require(path.join('/')); + }; + }; + + +require.register("browser/debug.js", function(module, exports, require){ + +module.exports = function(type){ + return function(){ + + } +}; +}); // module: browser/debug.js + +require.register("browser/diff.js", function(module, exports, require){ + +}); // module: browser/diff.js + +require.register("browser/events.js", function(module, exports, require){ + +/** + * Module exports. + */ + +exports.EventEmitter = EventEmitter; + +/** + * Check if `obj` is an array. + */ + +function isArray(obj) { + return '[object Array]' == {}.toString.call(obj); +} + +/** + * Event emitter constructor. + * + * @api public + */ + +function EventEmitter(){}; + +/** + * Adds a listener. + * + * @api public + */ + +EventEmitter.prototype.on = function (name, fn) { + if (!this.$events) { + this.$events = {}; + } + + if (!this.$events[name]) { + this.$events[name] = fn; + } else if (isArray(this.$events[name])) { + this.$events[name].push(fn); + } else { + this.$events[name] = [this.$events[name], fn]; + } + + return this; +}; + +EventEmitter.prototype.addListener = EventEmitter.prototype.on; + +/** + * Adds a volatile listener. + * + * @api public + */ + +EventEmitter.prototype.once = function (name, fn) { + var self = this; + + function on () { + self.removeListener(name, on); + fn.apply(this, arguments); + }; + + on.listener = fn; + this.on(name, on); + + return this; +}; + +/** + * Removes a listener. + * + * @api public + */ + +EventEmitter.prototype.removeListener = function (name, fn) { + if (this.$events && this.$events[name]) { + var list = this.$events[name]; + + if (isArray(list)) { + var pos = -1; + + for (var i = 0, l = list.length; i < l; i++) { + if (list[i] === fn || (list[i].listener && list[i].listener === fn)) { + pos = i; + break; + } + } + + if (pos < 0) { + return this; + } + + list.splice(pos, 1); + + if (!list.length) { + delete this.$events[name]; + } + } else if (list === fn || (list.listener && list.listener === fn)) { + delete this.$events[name]; + } + } + + return this; +}; + +/** + * Removes all listeners for an event. + * + * @api public + */ + +EventEmitter.prototype.removeAllListeners = function (name) { + if (name === undefined) { + this.$events = {}; + return this; + } + + if (this.$events && this.$events[name]) { + this.$events[name] = null; + } + + return this; +}; + +/** + * Gets all listeners for a certain event. + * + * @api public + */ + +EventEmitter.prototype.listeners = function (name) { + if (!this.$events) { + this.$events = {}; + } + + if (!this.$events[name]) { + this.$events[name] = []; + } + + if (!isArray(this.$events[name])) { + this.$events[name] = [this.$events[name]]; + } + + return this.$events[name]; +}; + +/** + * Emits an event. + * + * @api public + */ + +EventEmitter.prototype.emit = function (name) { + if (!this.$events) { + return false; + } + + var handler = this.$events[name]; + + if (!handler) { + return false; + } + + var args = [].slice.call(arguments, 1); + + if ('function' == typeof handler) { + handler.apply(this, args); + } else if (isArray(handler)) { + var listeners = handler.slice(); + + for (var i = 0, l = listeners.length; i < l; i++) { + listeners[i].apply(this, args); + } + } else { + return false; + } + + return true; +}; +}); // module: browser/events.js + +require.register("browser/fs.js", function(module, exports, require){ + +}); // module: browser/fs.js + +require.register("browser/path.js", function(module, exports, require){ + +}); // module: browser/path.js + +require.register("browser/progress.js", function(module, exports, require){ + +/** + * Expose `Progress`. + */ + +module.exports = Progress; + +/** + * Initialize a new `Progress` indicator. + */ + +function Progress() { + this.percent = 0; + this.size(0); + this.fontSize(11); + this.font('helvetica, arial, sans-serif'); +} + +/** + * Set progress size to `n`. + * + * @param {Number} n + * @return {Progress} for chaining + * @api public + */ + +Progress.prototype.size = function(n){ + this._size = n; + return this; +}; + +/** + * Set text to `str`. + * + * @param {String} str + * @return {Progress} for chaining + * @api public + */ + +Progress.prototype.text = function(str){ + this._text = str; + return this; +}; + +/** + * Set font size to `n`. + * + * @param {Number} n + * @return {Progress} for chaining + * @api public + */ + +Progress.prototype.fontSize = function(n){ + this._fontSize = n; + return this; +}; + +/** + * Set font `family`. + * + * @param {String} family + * @return {Progress} for chaining + */ + +Progress.prototype.font = function(family){ + this._font = family; + return this; +}; + +/** + * Update percentage to `n`. + * + * @param {Number} n + * @return {Progress} for chaining + */ + +Progress.prototype.update = function(n){ + this.percent = n; + return this; +}; + +/** + * Draw on `ctx`. + * + * @param {CanvasRenderingContext2d} ctx + * @return {Progress} for chaining + */ + +Progress.prototype.draw = function(ctx){ + var percent = Math.min(this.percent, 100) + , size = this._size + , half = size / 2 + , x = half + , y = half + , rad = half - 1 + , fontSize = this._fontSize; + + ctx.font = fontSize + 'px ' + this._font; + + var angle = Math.PI * 2 * (percent / 100); + ctx.clearRect(0, 0, size, size); + + // outer circle + ctx.strokeStyle = '#9f9f9f'; + ctx.beginPath(); + ctx.arc(x, y, rad, 0, angle, false); + ctx.stroke(); + + // inner circle + ctx.strokeStyle = '#eee'; + ctx.beginPath(); + ctx.arc(x, y, rad - 1, 0, angle, true); + ctx.stroke(); + + // text + var text = this._text || (percent | 0) + '%' + , w = ctx.measureText(text).width; + + ctx.fillText( + text + , x - w / 2 + 1 + , y + fontSize / 2 - 1); + + return this; +}; + +}); // module: browser/progress.js + +require.register("browser/tty.js", function(module, exports, require){ + +exports.isatty = function(){ + return true; +}; + +exports.getWindowSize = function(){ + return [window.innerHeight, window.innerWidth]; +}; +}); // module: browser/tty.js + +require.register("context.js", function(module, exports, require){ + +/** + * Expose `Context`. + */ + +module.exports = Context; + +/** + * Initialize a new `Context`. + * + * @api private + */ + +function Context(){} + +/** + * Set or get the context `Runnable` to `runnable`. + * + * @param {Runnable} runnable + * @return {Context} + * @api private + */ + +Context.prototype.runnable = function(runnable){ + if (0 == arguments.length) return this._runnable; + this.test = this._runnable = runnable; + return this; +}; + +/** + * Set test timeout `ms`. + * + * @param {Number} ms + * @return {Context} self + * @api private + */ + +Context.prototype.timeout = function(ms){ + this.runnable().timeout(ms); + return this; +}; + +/** + * Set test slowness threshold `ms`. + * + * @param {Number} ms + * @return {Context} self + * @api private + */ + +Context.prototype.slow = function(ms){ + this.runnable().slow(ms); + return this; +}; + +/** + * Inspect the context void of `._runnable`. + * + * @return {String} + * @api private + */ + +Context.prototype.inspect = function(){ + return JSON.stringify(this, function(key, val){ + if ('_runnable' == key) return; + if ('test' == key) return; + return val; + }, 2); +}; + +}); // module: context.js + +require.register("hook.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Runnable = require('./runnable'); + +/** + * Expose `Hook`. + */ + +module.exports = Hook; + +/** + * Initialize a new `Hook` with the given `title` and callback `fn`. + * + * @param {String} title + * @param {Function} fn + * @api private + */ + +function Hook(title, fn) { + Runnable.call(this, title, fn); + this.type = 'hook'; +} + +/** + * Inherit from `Runnable.prototype`. + */ + +Hook.prototype = new Runnable; +Hook.prototype.constructor = Hook; + + +/** + * Get or set the test `err`. + * + * @param {Error} err + * @return {Error} + * @api public + */ + +Hook.prototype.error = function(err){ + if (0 == arguments.length) { + var err = this._error; + this._error = null; + return err; + } + + this._error = err; +}; + + +}); // module: hook.js + +require.register("interfaces/bdd.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Suite = require('../suite') + , Test = require('../test'); + +/** + * BDD-style interface: + * + * describe('Array', function(){ + * describe('#indexOf()', function(){ + * it('should return -1 when not present', function(){ + * + * }); + * + * it('should return the index when present', function(){ + * + * }); + * }); + * }); + * + */ + +module.exports = function(suite){ + var suites = [suite]; + + suite.on('pre-require', function(context, file, mocha){ + + /** + * Execute before running tests. + */ + + context.before = function(fn){ + suites[0].beforeAll(fn); + }; + + /** + * Execute after running tests. + */ + + context.after = function(fn){ + suites[0].afterAll(fn); + }; + + /** + * Execute before each test case. + */ + + context.beforeEach = function(fn){ + suites[0].beforeEach(fn); + }; + + /** + * Execute after each test case. + */ + + context.afterEach = function(fn){ + suites[0].afterEach(fn); + }; + + /** + * Describe a "suite" with the given `title` + * and callback `fn` containing nested suites + * and/or tests. + */ + + context.describe = context.context = function(title, fn){ + var suite = Suite.create(suites[0], title); + suites.unshift(suite); + fn(); + suites.shift(); + return suite; + }; + + /** + * Pending describe. + */ + + context.xdescribe = + context.xcontext = + context.describe.skip = function(title, fn){ + var suite = Suite.create(suites[0], title); + suite.pending = true; + suites.unshift(suite); + fn(); + suites.shift(); + }; + + /** + * Exclusive suite. + */ + + context.describe.only = function(title, fn){ + var suite = context.describe(title, fn); + mocha.grep(suite.fullTitle()); + }; + + /** + * Describe a specification or test-case + * with the given `title` and callback `fn` + * acting as a thunk. + */ + + context.it = context.specify = function(title, fn){ + var suite = suites[0]; + if (suite.pending) var fn = null; + var test = new Test(title, fn); + suite.addTest(test); + return test; + }; + + /** + * Exclusive test-case. + */ + + context.it.only = function(title, fn){ + var test = context.it(title, fn); + mocha.grep(test.fullTitle()); + }; + + /** + * Pending test case. + */ + + context.xit = + context.xspecify = + context.it.skip = function(title){ + context.it(title); + }; + }); +}; + +}); // module: interfaces/bdd.js + +require.register("interfaces/exports.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Suite = require('../suite') + , Test = require('../test'); + +/** + * TDD-style interface: + * + * exports.Array = { + * '#indexOf()': { + * 'should return -1 when the value is not present': function(){ + * + * }, + * + * 'should return the correct index when the value is present': function(){ + * + * } + * } + * }; + * + */ + +module.exports = function(suite){ + var suites = [suite]; + + suite.on('require', visit); + + function visit(obj) { + var suite; + for (var key in obj) { + if ('function' == typeof obj[key]) { + var fn = obj[key]; + switch (key) { + case 'before': + suites[0].beforeAll(fn); + break; + case 'after': + suites[0].afterAll(fn); + break; + case 'beforeEach': + suites[0].beforeEach(fn); + break; + case 'afterEach': + suites[0].afterEach(fn); + break; + default: + suites[0].addTest(new Test(key, fn)); + } + } else { + var suite = Suite.create(suites[0], key); + suites.unshift(suite); + visit(obj[key]); + suites.shift(); + } + } + } +}; +}); // module: interfaces/exports.js + +require.register("interfaces/index.js", function(module, exports, require){ + +exports.bdd = require('./bdd'); +exports.tdd = require('./tdd'); +exports.qunit = require('./qunit'); +exports.exports = require('./exports'); + +}); // module: interfaces/index.js + +require.register("interfaces/qunit.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Suite = require('../suite') + , Test = require('../test'); + +/** + * QUnit-style interface: + * + * suite('Array'); + * + * test('#length', function(){ + * var arr = [1,2,3]; + * ok(arr.length == 3); + * }); + * + * test('#indexOf()', function(){ + * var arr = [1,2,3]; + * ok(arr.indexOf(1) == 0); + * ok(arr.indexOf(2) == 1); + * ok(arr.indexOf(3) == 2); + * }); + * + * suite('String'); + * + * test('#length', function(){ + * ok('foo'.length == 3); + * }); + * + */ + +module.exports = function(suite){ + var suites = [suite]; + + suite.on('pre-require', function(context){ + + /** + * Execute before running tests. + */ + + context.before = function(fn){ + suites[0].beforeAll(fn); + }; + + /** + * Execute after running tests. + */ + + context.after = function(fn){ + suites[0].afterAll(fn); + }; + + /** + * Execute before each test case. + */ + + context.beforeEach = function(fn){ + suites[0].beforeEach(fn); + }; + + /** + * Execute after each test case. + */ + + context.afterEach = function(fn){ + suites[0].afterEach(fn); + }; + + /** + * Describe a "suite" with the given `title`. + */ + + context.suite = function(title){ + if (suites.length > 1) suites.shift(); + var suite = Suite.create(suites[0], title); + suites.unshift(suite); + }; + + /** + * Describe a specification or test-case + * with the given `title` and callback `fn` + * acting as a thunk. + */ + + context.test = function(title, fn){ + suites[0].addTest(new Test(title, fn)); + }; + }); +}; + +}); // module: interfaces/qunit.js + +require.register("interfaces/tdd.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Suite = require('../suite') + , Test = require('../test'); + +/** + * TDD-style interface: + * + * suite('Array', function(){ + * suite('#indexOf()', function(){ + * suiteSetup(function(){ + * + * }); + * + * test('should return -1 when not present', function(){ + * + * }); + * + * test('should return the index when present', function(){ + * + * }); + * + * suiteTeardown(function(){ + * + * }); + * }); + * }); + * + */ + +module.exports = function(suite){ + var suites = [suite]; + + suite.on('pre-require', function(context, file, mocha){ + + /** + * Execute before each test case. + */ + + context.setup = function(fn){ + suites[0].beforeEach(fn); + }; + + /** + * Execute after each test case. + */ + + context.teardown = function(fn){ + suites[0].afterEach(fn); + }; + + /** + * Execute before the suite. + */ + + context.suiteSetup = function(fn){ + suites[0].beforeAll(fn); + }; + + /** + * Execute after the suite. + */ + + context.suiteTeardown = function(fn){ + suites[0].afterAll(fn); + }; + + /** + * Describe a "suite" with the given `title` + * and callback `fn` containing nested suites + * and/or tests. + */ + + context.suite = function(title, fn){ + var suite = Suite.create(suites[0], title); + suites.unshift(suite); + fn(); + suites.shift(); + return suite; + }; + + /** + * Exclusive test-case. + */ + + context.suite.only = function(title, fn){ + var suite = context.suite(title, fn); + mocha.grep(suite.fullTitle()); + }; + + /** + * Describe a specification or test-case + * with the given `title` and callback `fn` + * acting as a thunk. + */ + + context.test = function(title, fn){ + var test = new Test(title, fn); + suites[0].addTest(test); + return test; + }; + + /** + * Exclusive test-case. + */ + + context.test.only = function(title, fn){ + var test = context.test(title, fn); + mocha.grep(test.fullTitle()); + }; + }); +}; + +}); // module: interfaces/tdd.js + +require.register("mocha.js", function(module, exports, require){ +/*! + * mocha + * Copyright(c) 2011 TJ Holowaychuk <tj@vision-media.ca> + * MIT Licensed + */ + +/** + * Module dependencies. + */ + +var path = require('browser/path') + , utils = require('./utils'); + +/** + * Expose `Mocha`. + */ + +exports = module.exports = Mocha; + +/** + * Expose internals. + */ + +exports.utils = utils; +exports.interfaces = require('./interfaces'); +exports.reporters = require('./reporters'); +exports.Runnable = require('./runnable'); +exports.Context = require('./context'); +exports.Runner = require('./runner'); +exports.Suite = require('./suite'); +exports.Hook = require('./hook'); +exports.Test = require('./test'); + +/** + * Return image `name` path. + * + * @param {String} name + * @return {String} + * @api private + */ + +function image(name) { + return __dirname + '/../images/' + name + '.png'; +} + +/** + * Setup mocha with `options`. + * + * Options: + * + * - `ui` name "bdd", "tdd", "exports" etc + * - `reporter` reporter instance, defaults to `mocha.reporters.Dot` + * - `globals` array of accepted globals + * - `timeout` timeout in milliseconds + * - `slow` milliseconds to wait before considering a test slow + * - `ignoreLeaks` ignore global leaks + * - `grep` string or regexp to filter tests with + * + * @param {Object} options + * @api public + */ + +function Mocha(options) { + options = options || {}; + this.files = []; + this.options = options; + this.grep(options.grep); + this.suite = new exports.Suite('', new exports.Context); + this.ui(options.ui); + this.reporter(options.reporter); + if (options.timeout) this.timeout(options.timeout); + if (options.slow) this.slow(options.slow); +} + +/** + * Add test `file`. + * + * @param {String} file + * @api public + */ + +Mocha.prototype.addFile = function(file){ + this.files.push(file); + return this; +}; + +/** + * Set reporter to `reporter`, defaults to "dot". + * + * @param {String|Function} reporter name of a reporter or a reporter constructor + * @api public + */ + +Mocha.prototype.reporter = function(reporter){ + if ('function' == typeof reporter) { + this._reporter = reporter; + } else { + reporter = reporter || 'dot'; + try { + this._reporter = require('./reporters/' + reporter); + } catch (err) { + this._reporter = require(reporter); + } + if (!this._reporter) throw new Error('invalid reporter "' + reporter + '"'); + } + return this; +}; + +/** + * Set test UI `name`, defaults to "bdd". + * + * @param {String} bdd + * @api public + */ + +Mocha.prototype.ui = function(name){ + name = name || 'bdd'; + this._ui = exports.interfaces[name]; + if (!this._ui) throw new Error('invalid interface "' + name + '"'); + this._ui = this._ui(this.suite); + return this; +}; + +/** + * Load registered files. + * + * @api private + */ + +Mocha.prototype.loadFiles = function(fn){ + var self = this; + var suite = this.suite; + var pending = this.files.length; + this.files.forEach(function(file){ + file = path.resolve(file); + suite.emit('pre-require', global, file, self); + suite.emit('require', require(file), file, self); + suite.emit('post-require', global, file, self); + --pending || (fn && fn()); + }); +}; + +/** + * Enable growl support. + * + * @api private + */ + +Mocha.prototype._growl = function(runner, reporter) { + var notify = require('growl'); + + runner.on('end', function(){ + var stats = reporter.stats; + if (stats.failures) { + var msg = stats.failures + ' of ' + runner.total + ' tests failed'; + notify(msg, { name: 'mocha', title: 'Failed', image: image('error') }); + } else { + notify(stats.passes + ' tests passed in ' + stats.duration + 'ms', { + name: 'mocha' + , title: 'Passed' + , image: image('ok') + }); + } + }); +}; + +/** + * Add regexp to grep, if `re` is a string it is escaped. + * + * @param {RegExp|String} re + * @return {Mocha} + * @api public + */ + +Mocha.prototype.grep = function(re){ + this.options.grep = 'string' == typeof re + ? new RegExp(utils.escapeRegexp(re)) + : re; + return this; +}; + +/** + * Invert `.grep()` matches. + * + * @return {Mocha} + * @api public + */ + +Mocha.prototype.invert = function(){ + this.options.invert = true; + return this; +}; + +/** + * Ignore global leaks. + * + * @return {Mocha} + * @api public + */ + +Mocha.prototype.ignoreLeaks = function(){ + this.options.ignoreLeaks = true; + return this; +}; + +/** + * Enable global leak checking. + * + * @return {Mocha} + * @api public + */ + +Mocha.prototype.checkLeaks = function(){ + this.options.ignoreLeaks = false; + return this; +}; + +/** + * Enable growl support. + * + * @return {Mocha} + * @api public + */ + +Mocha.prototype.growl = function(){ + this.options.growl = true; + return this; +}; + +/** + * Ignore `globals` array or string. + * + * @param {Array|String} globals + * @return {Mocha} + * @api public + */ + +Mocha.prototype.globals = function(globals){ + this.options.globals = (this.options.globals || []).concat(globals); + return this; +}; + +/** + * Set the timeout in milliseconds. + * + * @param {Number} timeout + * @return {Mocha} + * @api public + */ + +Mocha.prototype.timeout = function(timeout){ + this.suite.timeout(timeout); + return this; +}; + +/** + * Set slowness threshold in milliseconds. + * + * @param {Number} slow + * @return {Mocha} + * @api public + */ + +Mocha.prototype.slow = function(slow){ + this.suite.slow(slow); + return this; +}; + +/** + * Run tests and invoke `fn()` when complete. + * + * @param {Function} fn + * @return {Runner} + * @api public + */ + +Mocha.prototype.run = function(fn){ + if (this.files.length) this.loadFiles(); + var suite = this.suite; + var options = this.options; + var runner = new exports.Runner(suite); + var reporter = new this._reporter(runner); + runner.ignoreLeaks = options.ignoreLeaks; + if (options.grep) runner.grep(options.grep, options.invert); + if (options.globals) runner.globals(options.globals); + if (options.growl) this._growl(runner, reporter); + return runner.run(fn); +}; + +}); // module: mocha.js + +require.register("ms.js", function(module, exports, require){ + +/** + * Helpers. + */ + +var s = 1000; +var m = s * 60; +var h = m * 60; +var d = h * 24; + +/** + * Parse or format the given `val`. + * + * @param {String|Number} val + * @return {String|Number} + * @api public + */ + +module.exports = function(val){ + if ('string' == typeof val) return parse(val); + return format(val); +} + +/** + * Parse the given `str` and return milliseconds. + * + * @param {String} str + * @return {Number} + * @api private + */ + +function parse(str) { + var m = /^((?:\d+)?\.?\d+) *(ms|seconds?|s|minutes?|m|hours?|h|days?|d|years?|y)?$/i.exec(str); + if (!m) return; + var n = parseFloat(m[1]); + var type = (m[2] || 'ms').toLowerCase(); + switch (type) { + case 'years': + case 'year': + case 'y': + return n * 31557600000; + case 'days': + case 'day': + case 'd': + return n * 86400000; + case 'hours': + case 'hour': + case 'h': + return n * 3600000; + case 'minutes': + case 'minute': + case 'm': + return n * 60000; + case 'seconds': + case 'second': + case 's': + return n * 1000; + case 'ms': + return n; + } +} + +/** + * Format the given `ms`. + * + * @param {Number} ms + * @return {String} + * @api public + */ + +function format(ms) { + if (ms == d) return (ms / d) + ' day'; + if (ms > d) return (ms / d) + ' days'; + if (ms == h) return (ms / h) + ' hour'; + if (ms > h) return (ms / h) + ' hours'; + if (ms == m) return (ms / m) + ' minute'; + if (ms > m) return (ms / m) + ' minutes'; + if (ms == s) return (ms / s) + ' second'; + if (ms > s) return (ms / s) + ' seconds'; + return ms + ' ms'; +} +}); // module: ms.js + +require.register("reporters/base.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var tty = require('browser/tty') + , diff = require('browser/diff') + , ms = require('../ms'); + +/** + * Save timer references to avoid Sinon interfering (see GH-237). + */ + +var Date = global.Date + , setTimeout = global.setTimeout + , setInterval = global.setInterval + , clearTimeout = global.clearTimeout + , clearInterval = global.clearInterval; + +/** + * Check if both stdio streams are associated with a tty. + */ + +var isatty = tty.isatty(1) && tty.isatty(2); + +/** + * Expose `Base`. + */ + +exports = module.exports = Base; + +/** + * Enable coloring by default. + */ + +exports.useColors = isatty; + +/** + * Default color map. + */ + +exports.colors = { + 'pass': 90 + , 'fail': 31 + , 'bright pass': 92 + , 'bright fail': 91 + , 'bright yellow': 93 + , 'pending': 36 + , 'suite': 0 + , 'error title': 0 + , 'error message': 31 + , 'error stack': 90 + , 'checkmark': 32 + , 'fast': 90 + , 'medium': 33 + , 'slow': 31 + , 'green': 32 + , 'light': 90 + , 'diff gutter': 90 + , 'diff added': 42 + , 'diff removed': 41 +}; + +/** + * Color `str` with the given `type`, + * allowing colors to be disabled, + * as well as user-defined color + * schemes. + * + * @param {String} type + * @param {String} str + * @return {String} + * @api private + */ + +var color = exports.color = function(type, str) { + if (!exports.useColors) return str; + return '\u001b[' + exports.colors[type] + 'm' + str + '\u001b[0m'; +}; + +/** + * Expose term window size, with some + * defaults for when stderr is not a tty. + */ + +exports.window = { + width: isatty + ? process.stdout.getWindowSize + ? process.stdout.getWindowSize(1)[0] + : tty.getWindowSize()[1] + : 75 +}; + +/** + * Expose some basic cursor interactions + * that are common among reporters. + */ + +exports.cursor = { + hide: function(){ + process.stdout.write('\u001b[?25l'); + }, + + show: function(){ + process.stdout.write('\u001b[?25h'); + }, + + deleteLine: function(){ + process.stdout.write('\u001b[2K'); + }, + + beginningOfLine: function(){ + process.stdout.write('\u001b[0G'); + }, + + CR: function(){ + exports.cursor.deleteLine(); + exports.cursor.beginningOfLine(); + } +}; + +/** + * Outut the given `failures` as a list. + * + * @param {Array} failures + * @api public + */ + +exports.list = function(failures){ + console.error(); + failures.forEach(function(test, i){ + // format + var fmt = color('error title', ' %s) %s:\n') + + color('error message', ' %s') + + color('error stack', '\n%s\n'); + + // msg + var err = test.err + , message = err.message || '' + , stack = err.stack || message + , index = stack.indexOf(message) + message.length + , msg = stack.slice(0, index) + , actual = err.actual + , expected = err.expected; + + // actual / expected diff + if ('string' == typeof actual && 'string' == typeof expected) { + var len = Math.max(actual.length, expected.length); + + if (len < 20) msg = errorDiff(err, 'Chars'); + else msg = errorDiff(err, 'Words'); + + // linenos + var lines = msg.split('\n'); + if (lines.length > 4) { + var width = String(lines.length).length; + msg = lines.map(function(str, i){ + return pad(++i, width) + ' |' + ' ' + str; + }).join('\n'); + } + + // legend + msg = '\n' + + color('diff removed', 'actual') + + ' ' + + color('diff added', 'expected') + + '\n\n' + + msg + + '\n'; + + // indent + msg = msg.replace(/^/gm, ' '); + + fmt = color('error title', ' %s) %s:\n%s') + + color('error stack', '\n%s\n'); + } + + // indent stack trace without msg + stack = stack.slice(index ? index + 1 : index) + .replace(/^/gm, ' '); + + console.error(fmt, (i + 1), test.fullTitle(), msg, stack); + }); +}; + +/** + * Initialize a new `Base` reporter. + * + * All other reporters generally + * inherit from this reporter, providing + * stats such as test duration, number + * of tests passed / failed etc. + * + * @param {Runner} runner + * @api public + */ + +function Base(runner) { + var self = this + , stats = this.stats = { suites: 0, tests: 0, passes: 0, pending: 0, failures: 0 } + , failures = this.failures = []; + + if (!runner) return; + this.runner = runner; + + runner.on('start', function(){ + stats.start = new Date; + }); + + runner.on('suite', function(suite){ + stats.suites = stats.suites || 0; + suite.root || stats.suites++; + }); + + runner.on('test end', function(test){ + stats.tests = stats.tests || 0; + stats.tests++; + }); + + runner.on('pass', function(test){ + stats.passes = stats.passes || 0; + + var medium = test.slow() / 2; + test.speed = test.duration > test.slow() + ? 'slow' + : test.duration > medium + ? 'medium' + : 'fast'; + + stats.passes++; + }); + + runner.on('fail', function(test, err){ + stats.failures = stats.failures || 0; + stats.failures++; + test.err = err; + failures.push(test); + }); + + runner.on('end', function(){ + stats.end = new Date; + stats.duration = new Date - stats.start; + }); + + runner.on('pending', function(){ + stats.pending++; + }); +} + +/** + * Output common epilogue used by many of + * the bundled reporters. + * + * @api public + */ + +Base.prototype.epilogue = function(){ + var stats = this.stats + , fmt + , tests; + + console.log(); + + function pluralize(n) { + return 1 == n ? 'test' : 'tests'; + } + + // failure + if (stats.failures) { + fmt = color('bright fail', ' ✖') + + color('fail', ' %d of %d %s failed') + + color('light', ':') + + console.error(fmt, + stats.failures, + this.runner.total, + pluralize(this.runner.total)); + + Base.list(this.failures); + console.error(); + return; + } + + // pass + fmt = color('bright pass', ' ✔') + + color('green', ' %d %s complete') + + color('light', ' (%s)'); + + console.log(fmt, + stats.tests || 0, + pluralize(stats.tests), + ms(stats.duration)); + + // pending + if (stats.pending) { + fmt = color('pending', ' •') + + color('pending', ' %d %s pending'); + + console.log(fmt, stats.pending, pluralize(stats.pending)); + } + + console.log(); +}; + +/** + * Pad the given `str` to `len`. + * + * @param {String} str + * @param {String} len + * @return {String} + * @api private + */ + +function pad(str, len) { + str = String(str); + return Array(len - str.length + 1).join(' ') + str; +} + +/** + * Return a character diff for `err`. + * + * @param {Error} err + * @return {String} + * @api private + */ + +function errorDiff(err, type) { + return diff['diff' + type](err.actual, err.expected).map(function(str){ + str.value = str.value + .replace(/\t/g, '<tab>') + .replace(/\r/g, '<CR>') + .replace(/\n/g, '<LF>\n'); + if (str.added) return colorLines('diff added', str.value); + if (str.removed) return colorLines('diff removed', str.value); + return str.value; + }).join(''); +} + +/** + * Color lines for `str`, using the color `name`. + * + * @param {String} name + * @param {String} str + * @return {String} + * @api private + */ + +function colorLines(name, str) { + return str.split('\n').map(function(str){ + return color(name, str); + }).join('\n'); +} + +}); // module: reporters/base.js + +require.register("reporters/doc.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base') + , utils = require('../utils'); + +/** + * Expose `Doc`. + */ + +exports = module.exports = Doc; + +/** + * Initialize a new `Doc` reporter. + * + * @param {Runner} runner + * @api public + */ + +function Doc(runner) { + Base.call(this, runner); + + var self = this + , stats = this.stats + , total = runner.total + , indents = 2; + + function indent() { + return Array(indents).join(' '); + } + + runner.on('suite', function(suite){ + if (suite.root) return; + ++indents; + console.log('%s<section class="suite">', indent()); + ++indents; + console.log('%s<h1>%s</h1>', indent(), suite.title); + console.log('%s<dl>', indent()); + }); + + runner.on('suite end', function(suite){ + if (suite.root) return; + console.log('%s</dl>', indent()); + --indents; + console.log('%s</section>', indent()); + --indents; + }); + + runner.on('pass', function(test){ + console.log('%s <dt>%s</dt>', indent(), test.title); + var code = utils.escape(utils.clean(test.fn.toString())); + console.log('%s <dd><pre><code>%s</code></pre></dd>', indent(), code); + }); +} + +}); // module: reporters/doc.js + +require.register("reporters/dot.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base') + , color = Base.color; + +/** + * Expose `Dot`. + */ + +exports = module.exports = Dot; + +/** + * Initialize a new `Dot` matrix test reporter. + * + * @param {Runner} runner + * @api public + */ + +function Dot(runner) { + Base.call(this, runner); + + var self = this + , stats = this.stats + , width = Base.window.width * .75 | 0 + , c = '․' + , n = 0; + + runner.on('start', function(){ + process.stdout.write('\n '); + }); + + runner.on('pending', function(test){ + process.stdout.write(color('pending', c)); + }); + + runner.on('pass', function(test){ + if (++n % width == 0) process.stdout.write('\n '); + if ('slow' == test.speed) { + process.stdout.write(color('bright yellow', c)); + } else { + process.stdout.write(color(test.speed, c)); + } + }); + + runner.on('fail', function(test, err){ + if (++n % width == 0) process.stdout.write('\n '); + process.stdout.write(color('fail', c)); + }); + + runner.on('end', function(){ + console.log(); + self.epilogue(); + }); +} + +/** + * Inherit from `Base.prototype`. + */ + +Dot.prototype = new Base; +Dot.prototype.constructor = Dot; + +}); // module: reporters/dot.js + +require.register("reporters/html-cov.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var JSONCov = require('./json-cov') + , fs = require('browser/fs'); + +/** + * Expose `HTMLCov`. + */ + +exports = module.exports = HTMLCov; + +/** + * Initialize a new `JsCoverage` reporter. + * + * @param {Runner} runner + * @api public + */ + +function HTMLCov(runner) { + var jade = require('jade') + , file = __dirname + '/templates/coverage.jade' + , str = fs.readFileSync(file, 'utf8') + , fn = jade.compile(str, { filename: file }) + , self = this; + + JSONCov.call(this, runner, false); + + runner.on('end', function(){ + process.stdout.write(fn({ + cov: self.cov + , coverageClass: coverageClass + })); + }); +} + +/** + * Return coverage class for `n`. + * + * @return {String} + * @api private + */ + +function coverageClass(n) { + if (n >= 75) return 'high'; + if (n >= 50) return 'medium'; + if (n >= 25) return 'low'; + return 'terrible'; +} +}); // module: reporters/html-cov.js + +require.register("reporters/html.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base') + , utils = require('../utils') + , Progress = require('../browser/progress') + , escape = utils.escape; + +/** + * Save timer references to avoid Sinon interfering (see GH-237). + */ + +var Date = global.Date + , setTimeout = global.setTimeout + , setInterval = global.setInterval + , clearTimeout = global.clearTimeout + , clearInterval = global.clearInterval; + +/** + * Expose `Doc`. + */ + +exports = module.exports = HTML; + +/** + * Stats template. + */ + +var statsTemplate = '<ul id="stats">' + + '<li class="progress"><canvas width="40" height="40"></canvas></li>' + + '<li class="passes"><a href="#">passes:</a> <em>0</em></li>' + + '<li class="failures"><a href="#">failures:</a> <em>0</em></li>' + + '<li class="duration">duration: <em>0</em>s</li>' + + '</ul>'; + +/** + * Initialize a new `Doc` reporter. + * + * @param {Runner} runner + * @api public + */ + +function HTML(runner, root) { + Base.call(this, runner); + + var self = this + , stats = this.stats + , total = runner.total + , stat = fragment(statsTemplate) + , items = stat.getElementsByTagName('li') + , passes = items[1].getElementsByTagName('em')[0] + , passesLink = items[1].getElementsByTagName('a')[0] + , failures = items[2].getElementsByTagName('em')[0] + , failuresLink = items[2].getElementsByTagName('a')[0] + , duration = items[3].getElementsByTagName('em')[0] + , canvas = stat.getElementsByTagName('canvas')[0] + , report = fragment('<ul id="report"></ul>') + , stack = [report] + , progress + , ctx + + root = root || document.getElementById('mocha'); + + if (canvas.getContext) { + var ratio = window.devicePixelRatio || 1; + canvas.style.width = canvas.width; + canvas.style.height = canvas.height; + canvas.width *= ratio; + canvas.height *= ratio; + ctx = canvas.getContext('2d'); + ctx.scale(ratio, ratio); + progress = new Progress; + } + + if (!root) return error('#mocha div missing, add it to your document'); + + // pass toggle + on(passesLink, 'click', function () { + var className = /pass/.test(report.className) ? '' : ' pass'; + report.className = report.className.replace(/fail|pass/g, '') + className; + }); + + // failure toggle + on(failuresLink, 'click', function () { + var className = /fail/.test(report.className) ? '' : ' fail'; + report.className = report.className.replace(/fail|pass/g, '') + className; + }); + + root.appendChild(stat); + root.appendChild(report); + + if (progress) progress.size(40); + + runner.on('suite', function(suite){ + if (suite.root) return; + + // suite + var url = '?grep=' + encodeURIComponent(suite.fullTitle()); + var el = fragment('<li class="suite"><h1><a href="%s">%s</a></h1></li>', url, escape(suite.title)); + + // container + stack[0].appendChild(el); + stack.unshift(document.createElement('ul')); + el.appendChild(stack[0]); + }); + + runner.on('suite end', function(suite){ + if (suite.root) return; + stack.shift(); + }); + + runner.on('fail', function(test, err){ + if ('hook' == test.type || err.uncaught) runner.emit('test end', test); + }); + + runner.on('test end', function(test){ + window.scrollTo(0, document.body.scrollHeight); + + // TODO: add to stats + var percent = stats.tests / total * 100 | 0; + if (progress) progress.update(percent).draw(ctx); + + // update stats + var ms = new Date - stats.start; + text(passes, stats.passes); + text(failures, stats.failures); + text(duration, (ms / 1000).toFixed(2)); + + // test + if ('passed' == test.state) { + var el = fragment('<li class="test pass %e"><h2>%e<span class="duration">%ems</span></h2></li>', test.speed, test.title, test.duration); + } else if (test.pending) { + var el = fragment('<li class="test pass pending"><h2>%e</h2></li>', test.title); + } else { + var el = fragment('<li class="test fail"><h2>%e</h2></li>', test.title); + var str = test.err.stack || test.err.toString(); + + // FF / Opera do not add the message + if (!~str.indexOf(test.err.message)) { + str = test.err.message + '\n' + str; + } + + // <=IE7 stringifies to [Object Error]. Since it can be overloaded, we + // check for the result of the stringifying. + if ('[object Error]' == str) str = test.err.message; + + // Safari doesn't give you a stack. Let's at least provide a source line. + if (!test.err.stack && test.err.sourceURL && test.err.line !== undefined) { + str += "\n(" + test.err.sourceURL + ":" + test.err.line + ")"; + } + + el.appendChild(fragment('<pre class="error">%e</pre>', str)); + } + + // toggle code + // TODO: defer + if (!test.pending) { + var h2 = el.getElementsByTagName('h2')[0]; + + on(h2, 'click', function(){ + pre.style.display = 'none' == pre.style.display + ? 'inline-block' + : 'none'; + }); + + var pre = fragment('<pre><code>%e</code></pre>', utils.clean(test.fn.toString())); + el.appendChild(pre); + pre.style.display = 'none'; + } + + stack[0].appendChild(el); + }); +} + +/** + * Display error `msg`. + */ + +function error(msg) { + document.body.appendChild(fragment('<div id="error">%s</div>', msg)); +} + +/** + * Return a DOM fragment from `html`. + */ + +function fragment(html) { + var args = arguments + , div = document.createElement('div') + , i = 1; + + div.innerHTML = html.replace(/%([se])/g, function(_, type){ + switch (type) { + case 's': return String(args[i++]); + case 'e': return escape(args[i++]); + } + }); + + return div.firstChild; +} + +/** + * Set `el` text to `str`. + */ + +function text(el, str) { + if (el.textContent) { + el.textContent = str; + } else { + el.innerText = str; + } +} + +/** + * Listen on `event` with callback `fn`. + */ + +function on(el, event, fn) { + if (el.addEventListener) { + el.addEventListener(event, fn, false); + } else { + el.attachEvent('on' + event, fn); + } +} + +}); // module: reporters/html.js + +require.register("reporters/index.js", function(module, exports, require){ + +exports.Base = require('./base'); +exports.Dot = require('./dot'); +exports.Doc = require('./doc'); +exports.TAP = require('./tap'); +exports.JSON = require('./json'); +exports.HTML = require('./html'); +exports.List = require('./list'); +exports.Min = require('./min'); +exports.Spec = require('./spec'); +exports.Nyan = require('./nyan'); +exports.XUnit = require('./xunit'); +exports.Markdown = require('./markdown'); +exports.Progress = require('./progress'); +exports.Landing = require('./landing'); +exports.JSONCov = require('./json-cov'); +exports.HTMLCov = require('./html-cov'); +exports.JSONStream = require('./json-stream'); +exports.Teamcity = require('./teamcity'); + +}); // module: reporters/index.js + +require.register("reporters/json-cov.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base'); + +/** + * Expose `JSONCov`. + */ + +exports = module.exports = JSONCov; + +/** + * Initialize a new `JsCoverage` reporter. + * + * @param {Runner} runner + * @param {Boolean} output + * @api public + */ + +function JSONCov(runner, output) { + var self = this + , output = 1 == arguments.length ? true : output; + + Base.call(this, runner); + + var tests = [] + , failures = [] + , passes = []; + + runner.on('test end', function(test){ + tests.push(test); + }); + + runner.on('pass', function(test){ + passes.push(test); + }); + + runner.on('fail', function(test){ + failures.push(test); + }); + + runner.on('end', function(){ + var cov = global._$jscoverage || {}; + var result = self.cov = map(cov); + result.stats = self.stats; + result.tests = tests.map(clean); + result.failures = failures.map(clean); + result.passes = passes.map(clean); + if (!output) return; + process.stdout.write(JSON.stringify(result, null, 2 )); + }); +} + +/** + * Map jscoverage data to a JSON structure + * suitable for reporting. + * + * @param {Object} cov + * @return {Object} + * @api private + */ + +function map(cov) { + var ret = { + instrumentation: 'node-jscoverage' + , sloc: 0 + , hits: 0 + , misses: 0 + , coverage: 0 + , files: [] + }; + + for (var filename in cov) { + var data = coverage(filename, cov[filename]); + ret.files.push(data); + ret.hits += data.hits; + ret.misses += data.misses; + ret.sloc += data.sloc; + } + + if (ret.sloc > 0) { + ret.coverage = (ret.hits / ret.sloc) * 100; + } + + return ret; +}; + +/** + * Map jscoverage data for a single source file + * to a JSON structure suitable for reporting. + * + * @param {String} filename name of the source file + * @param {Object} data jscoverage coverage data + * @return {Object} + * @api private + */ + +function coverage(filename, data) { + var ret = { + filename: filename, + coverage: 0, + hits: 0, + misses: 0, + sloc: 0, + source: {} + }; + + data.source.forEach(function(line, num){ + num++; + + if (data[num] === 0) { + ret.misses++; + ret.sloc++; + } else if (data[num] !== undefined) { + ret.hits++; + ret.sloc++; + } + + ret.source[num] = { + source: line + , coverage: data[num] === undefined + ? '' + : data[num] + }; + }); + + ret.coverage = ret.hits / ret.sloc * 100; + + return ret; +} + +/** + * Return a plain-object representation of `test` + * free of cyclic properties etc. + * + * @param {Object} test + * @return {Object} + * @api private + */ + +function clean(test) { + return { + title: test.title + , fullTitle: test.fullTitle() + , duration: test.duration + } +} + +}); // module: reporters/json-cov.js + +require.register("reporters/json-stream.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base') + , color = Base.color; + +/** + * Expose `List`. + */ + +exports = module.exports = List; + +/** + * Initialize a new `List` test reporter. + * + * @param {Runner} runner + * @api public + */ + +function List(runner) { + Base.call(this, runner); + + var self = this + , stats = this.stats + , total = runner.total; + + runner.on('start', function(){ + console.log(JSON.stringify(['start', { total: total }])); + }); + + runner.on('pass', function(test){ + console.log(JSON.stringify(['pass', clean(test)])); + }); + + runner.on('fail', function(test, err){ + console.log(JSON.stringify(['fail', clean(test)])); + }); + + runner.on('end', function(){ + process.stdout.write(JSON.stringify(['end', self.stats])); + }); +} + +/** + * Return a plain-object representation of `test` + * free of cyclic properties etc. + * + * @param {Object} test + * @return {Object} + * @api private + */ + +function clean(test) { + return { + title: test.title + , fullTitle: test.fullTitle() + , duration: test.duration + } +} +}); // module: reporters/json-stream.js + +require.register("reporters/json.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base') + , cursor = Base.cursor + , color = Base.color; + +/** + * Expose `JSON`. + */ + +exports = module.exports = JSONReporter; + +/** + * Initialize a new `JSON` reporter. + * + * @param {Runner} runner + * @api public + */ + +function JSONReporter(runner) { + var self = this; + Base.call(this, runner); + + var tests = [] + , failures = [] + , passes = []; + + runner.on('test end', function(test){ + tests.push(test); + }); + + runner.on('pass', function(test){ + passes.push(test); + }); + + runner.on('fail', function(test){ + failures.push(test); + }); + + runner.on('end', function(){ + var obj = { + stats: self.stats + , tests: tests.map(clean) + , failures: failures.map(clean) + , passes: passes.map(clean) + }; + + process.stdout.write(JSON.stringify(obj, null, 2)); + }); +} + +/** + * Return a plain-object representation of `test` + * free of cyclic properties etc. + * + * @param {Object} test + * @return {Object} + * @api private + */ + +function clean(test) { + return { + title: test.title + , fullTitle: test.fullTitle() + , duration: test.duration + } +} +}); // module: reporters/json.js + +require.register("reporters/landing.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base') + , cursor = Base.cursor + , color = Base.color; + +/** + * Expose `Landing`. + */ + +exports = module.exports = Landing; + +/** + * Airplane color. + */ + +Base.colors.plane = 0; + +/** + * Airplane crash color. + */ + +Base.colors['plane crash'] = 31; + +/** + * Runway color. + */ + +Base.colors.runway = 90; + +/** + * Initialize a new `Landing` reporter. + * + * @param {Runner} runner + * @api public + */ + +function Landing(runner) { + Base.call(this, runner); + + var self = this + , stats = this.stats + , width = Base.window.width * .75 | 0 + , total = runner.total + , stream = process.stdout + , plane = color('plane', '✈') + , crashed = -1 + , n = 0; + + function runway() { + var buf = Array(width).join('-'); + return ' ' + color('runway', buf); + } + + runner.on('start', function(){ + stream.write('\n '); + cursor.hide(); + }); + + runner.on('test end', function(test){ + // check if the plane crashed + var col = -1 == crashed + ? width * ++n / total | 0 + : crashed; + + // show the crash + if ('failed' == test.state) { + plane = color('plane crash', '✈'); + crashed = col; + } + + // render landing strip + stream.write('\u001b[4F\n\n'); + stream.write(runway()); + stream.write('\n '); + stream.write(color('runway', Array(col).join('⋅'))); + stream.write(plane) + stream.write(color('runway', Array(width - col).join('⋅') + '\n')); + stream.write(runway()); + stream.write('\u001b[0m'); + }); + + runner.on('end', function(){ + cursor.show(); + console.log(); + self.epilogue(); + }); +} + +/** + * Inherit from `Base.prototype`. + */ + +Landing.prototype = new Base; +Landing.prototype.constructor = Landing; + +}); // module: reporters/landing.js + +require.register("reporters/list.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base') + , cursor = Base.cursor + , color = Base.color; + +/** + * Expose `List`. + */ + +exports = module.exports = List; + +/** + * Initialize a new `List` test reporter. + * + * @param {Runner} runner + * @api public + */ + +function List(runner) { + Base.call(this, runner); + + var self = this + , stats = this.stats + , n = 0; + + runner.on('start', function(){ + console.log(); + }); + + runner.on('test', function(test){ + process.stdout.write(color('pass', ' ' + test.fullTitle() + ': ')); + }); + + runner.on('pending', function(test){ + var fmt = color('checkmark', ' -') + + color('pending', ' %s'); + console.log(fmt, test.fullTitle()); + }); + + runner.on('pass', function(test){ + var fmt = color('checkmark', ' ✓') + + color('pass', ' %s: ') + + color(test.speed, '%dms'); + cursor.CR(); + console.log(fmt, test.fullTitle(), test.duration); + }); + + runner.on('fail', function(test, err){ + cursor.CR(); + console.log(color('fail', ' %d) %s'), ++n, test.fullTitle()); + }); + + runner.on('end', self.epilogue.bind(self)); +} + +/** + * Inherit from `Base.prototype`. + */ + +List.prototype = new Base; +List.prototype.constructor = List; + + +}); // module: reporters/list.js + +require.register("reporters/markdown.js", function(module, exports, require){ +/** + * Module dependencies. + */ + +var Base = require('./base') + , utils = require('../utils'); + +/** + * Expose `Markdown`. + */ + +exports = module.exports = Markdown; + +/** + * Initialize a new `Markdown` reporter. + * + * @param {Runner} runner + * @api public + */ + +function Markdown(runner) { + Base.call(this, runner); + + var self = this + , stats = this.stats + , total = runner.total + , level = 0 + , buf = ''; + + function title(str) { + return Array(level).join('#') + ' ' + str; + } + + function indent() { + return Array(level).join(' '); + } + + function mapTOC(suite, obj) { + var ret = obj; + obj = obj[suite.title] = obj[suite.title] || { suite: suite }; + suite.suites.forEach(function(suite){ + mapTOC(suite, obj); + }); + return ret; + } + + function stringifyTOC(obj, level) { + ++level; + var buf = ''; + var link; + for (var key in obj) { + if ('suite' == key) continue; + if (key) link = ' - [' + key + '](#' + utils.slug(obj[key].suite.fullTitle()) + ')\n'; + if (key) buf += Array(level).join(' ') + link; + buf += stringifyTOC(obj[key], level); + } + --level; + return buf; + } + + function generateTOC(suite) { + var obj = mapTOC(suite, {}); + return stringifyTOC(obj, 0); + } + + generateTOC(runner.suite); + + runner.on('suite', function(suite){ + ++level; + var slug = utils.slug(suite.fullTitle()); + buf += '<a name="' + slug + '" />' + '\n'; + buf += title(suite.title) + '\n'; + }); + + runner.on('suite end', function(suite){ + --level; + }); + + runner.on('pass', function(test){ + var code = utils.clean(test.fn.toString()); + buf += test.title + '.\n'; + buf += '\n```js\n'; + buf += code + '\n'; + buf += '```\n\n'; + }); + + runner.on('end', function(){ + process.stdout.write('# TOC\n'); + process.stdout.write(generateTOC(runner.suite)); + process.stdout.write(buf); + }); +} +}); // module: reporters/markdown.js + +require.register("reporters/min.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base'); + +/** + * Expose `Min`. + */ + +exports = module.exports = Min; + +/** + * Initialize a new `Min` minimal test reporter (best used with --watch). + * + * @param {Runner} runner + * @api public + */ + +function Min(runner) { + Base.call(this, runner); + + runner.on('start', function(){ + // clear screen + process.stdout.write('\u001b[2J'); + // set cursor position + process.stdout.write('\u001b[1;3H'); + }); + + runner.on('end', this.epilogue.bind(this)); +} + +/** + * Inherit from `Base.prototype`. + */ + +Min.prototype = new Base; +Min.prototype.constructor = Min; + +}); // module: reporters/min.js + +require.register("reporters/nyan.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base') + , color = Base.color; + +/** + * Expose `Dot`. + */ + +exports = module.exports = NyanCat; + +/** + * Initialize a new `Dot` matrix test reporter. + * + * @param {Runner} runner + * @api public + */ + +function NyanCat(runner) { + Base.call(this, runner); + + var self = this + , stats = this.stats + , width = Base.window.width * .75 | 0 + , rainbowColors = this.rainbowColors = self.generateColors() + , colorIndex = this.colorIndex = 0 + , numerOfLines = this.numberOfLines = 4 + , trajectories = this.trajectories = [[], [], [], []] + , nyanCatWidth = this.nyanCatWidth = 11 + , trajectoryWidthMax = this.trajectoryWidthMax = (width - nyanCatWidth) + , scoreboardWidth = this.scoreboardWidth = 5 + , tick = this.tick = 0 + , n = 0; + + runner.on('start', function(){ + Base.cursor.hide(); + self.draw('start'); + }); + + runner.on('pending', function(test){ + self.draw('pending'); + }); + + runner.on('pass', function(test){ + self.draw('pass'); + }); + + runner.on('fail', function(test, err){ + self.draw('fail'); + }); + + runner.on('end', function(){ + Base.cursor.show(); + for (var i = 0; i < self.numberOfLines; i++) write('\n'); + self.epilogue(); + }); +} + +/** + * Draw the nyan cat with runner `status`. + * + * @param {String} status + * @api private + */ + +NyanCat.prototype.draw = function(status){ + this.appendRainbow(); + this.drawScoreboard(); + this.drawRainbow(); + this.drawNyanCat(status); + this.tick = !this.tick; +}; + +/** + * Draw the "scoreboard" showing the number + * of passes, failures and pending tests. + * + * @api private + */ + +NyanCat.prototype.drawScoreboard = function(){ + var stats = this.stats; + var colors = Base.colors; + + function draw(color, n) { + write(' '); + write('\u001b[' + color + 'm' + n + '\u001b[0m'); + write('\n'); + } + + draw(colors.green, stats.passes); + draw(colors.fail, stats.failures); + draw(colors.pending, stats.pending); + write('\n'); + + this.cursorUp(this.numberOfLines); +}; + +/** + * Append the rainbow. + * + * @api private + */ + +NyanCat.prototype.appendRainbow = function(){ + var segment = this.tick ? '_' : '-'; + var rainbowified = this.rainbowify(segment); + + for (var index = 0; index < this.numberOfLines; index++) { + var trajectory = this.trajectories[index]; + if (trajectory.length >= this.trajectoryWidthMax) trajectory.shift(); + trajectory.push(rainbowified); + } +}; + +/** + * Draw the rainbow. + * + * @api private + */ + +NyanCat.prototype.drawRainbow = function(){ + var self = this; + + this.trajectories.forEach(function(line, index) { + write('\u001b[' + self.scoreboardWidth + 'C'); + write(line.join('')); + write('\n'); + }); + + this.cursorUp(this.numberOfLines); +}; + +/** + * Draw the nyan cat with `status`. + * + * @param {String} status + * @api private + */ + +NyanCat.prototype.drawNyanCat = function(status) { + var self = this; + var startWidth = this.scoreboardWidth + this.trajectories[0].length; + + [0, 1, 2, 3].forEach(function(index) { + write('\u001b[' + startWidth + 'C'); + + switch (index) { + case 0: + write('_,------,'); + write('\n'); + break; + case 1: + var padding = self.tick ? ' ' : ' '; + write('_|' + padding + '/\\_/\\ '); + write('\n'); + break; + case 2: + var padding = self.tick ? '_' : '__'; + var tail = self.tick ? '~' : '^'; + var face; + switch (status) { + case 'pass': + face = '( ^ .^)'; + break; + case 'fail': + face = '( o .o)'; + break; + default: + face = '( - .-)'; + } + write(tail + '|' + padding + face + ' '); + write('\n'); + break; + case 3: + var padding = self.tick ? ' ' : ' '; + write(padding + '"" "" '); + write('\n'); + break; + } + }); + + this.cursorUp(this.numberOfLines); +}; + +/** + * Move cursor up `n`. + * + * @param {Number} n + * @api private + */ + +NyanCat.prototype.cursorUp = function(n) { + write('\u001b[' + n + 'A'); +}; + +/** + * Move cursor down `n`. + * + * @param {Number} n + * @api private + */ + +NyanCat.prototype.cursorDown = function(n) { + write('\u001b[' + n + 'B'); +}; + +/** + * Generate rainbow colors. + * + * @return {Array} + * @api private + */ + +NyanCat.prototype.generateColors = function(){ + var colors = []; + + for (var i = 0; i < (6 * 7); i++) { + var pi3 = Math.floor(Math.PI / 3); + var n = (i * (1.0 / 6)); + var r = Math.floor(3 * Math.sin(n) + 3); + var g = Math.floor(3 * Math.sin(n + 2 * pi3) + 3); + var b = Math.floor(3 * Math.sin(n + 4 * pi3) + 3); + colors.push(36 * r + 6 * g + b + 16); + } + + return colors; +}; + +/** + * Apply rainbow to the given `str`. + * + * @param {String} str + * @return {String} + * @api private + */ + +NyanCat.prototype.rainbowify = function(str){ + var color = this.rainbowColors[this.colorIndex % this.rainbowColors.length]; + this.colorIndex += 1; + return '\u001b[38;5;' + color + 'm' + str + '\u001b[0m'; +}; + +/** + * Stdout helper. + */ + +function write(string) { + process.stdout.write(string); +} + +/** + * Inherit from `Base.prototype`. + */ + +NyanCat.prototype = new Base; +NyanCat.prototype.constructor = NyanCat; + + +}); // module: reporters/nyan.js + +require.register("reporters/progress.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base') + , cursor = Base.cursor + , color = Base.color; + +/** + * Expose `Progress`. + */ + +exports = module.exports = Progress; + +/** + * General progress bar color. + */ + +Base.colors.progress = 90; + +/** + * Initialize a new `Progress` bar test reporter. + * + * @param {Runner} runner + * @param {Object} options + * @api public + */ + +function Progress(runner, options) { + Base.call(this, runner); + + var self = this + , options = options || {} + , stats = this.stats + , width = Base.window.width * .50 | 0 + , total = runner.total + , complete = 0 + , max = Math.max; + + // default chars + options.open = options.open || '['; + options.complete = options.complete || '▬'; + options.incomplete = options.incomplete || '⋅'; + options.close = options.close || ']'; + options.verbose = false; + + // tests started + runner.on('start', function(){ + console.log(); + cursor.hide(); + }); + + // tests complete + runner.on('test end', function(){ + complete++; + var incomplete = total - complete + , percent = complete / total + , n = width * percent | 0 + , i = width - n; + + cursor.CR(); + process.stdout.write('\u001b[J'); + process.stdout.write(color('progress', ' ' + options.open)); + process.stdout.write(Array(n).join(options.complete)); + process.stdout.write(Array(i).join(options.incomplete)); + process.stdout.write(color('progress', options.close)); + if (options.verbose) { + process.stdout.write(color('progress', ' ' + complete + ' of ' + total)); + } + }); + + // tests are complete, output some stats + // and the failures if any + runner.on('end', function(){ + cursor.show(); + console.log(); + self.epilogue(); + }); +} + +/** + * Inherit from `Base.prototype`. + */ + +Progress.prototype = new Base; +Progress.prototype.constructor = Progress; + + +}); // module: reporters/progress.js + +require.register("reporters/spec.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base') + , cursor = Base.cursor + , color = Base.color; + +/** + * Expose `Spec`. + */ + +exports = module.exports = Spec; + +/** + * Initialize a new `Spec` test reporter. + * + * @param {Runner} runner + * @api public + */ + +function Spec(runner) { + Base.call(this, runner); + + var self = this + , stats = this.stats + , indents = 0 + , n = 0; + + function indent() { + return Array(indents).join(' ') + } + + runner.on('start', function(){ + console.log(); + }); + + runner.on('suite', function(suite){ + ++indents; + console.log(color('suite', '%s%s'), indent(), suite.title); + }); + + runner.on('suite end', function(suite){ + --indents; + if (1 == indents) console.log(); + }); + + runner.on('test', function(test){ + process.stdout.write(indent() + color('pass', ' ◦ ' + test.title + ': ')); + }); + + runner.on('pending', function(test){ + var fmt = indent() + color('pending', ' - %s'); + console.log(fmt, test.title); + }); + + runner.on('pass', function(test){ + if ('fast' == test.speed) { + var fmt = indent() + + color('checkmark', ' ✓') + + color('pass', ' %s '); + cursor.CR(); + console.log(fmt, test.title); + } else { + var fmt = indent() + + color('checkmark', ' ✓') + + color('pass', ' %s ') + + color(test.speed, '(%dms)'); + cursor.CR(); + console.log(fmt, test.title, test.duration); + } + }); + + runner.on('fail', function(test, err){ + cursor.CR(); + console.log(indent() + color('fail', ' %d) %s'), ++n, test.title); + }); + + runner.on('end', self.epilogue.bind(self)); +} + +/** + * Inherit from `Base.prototype`. + */ + +Spec.prototype = new Base; +Spec.prototype.constructor = Spec; + + +}); // module: reporters/spec.js + +require.register("reporters/tap.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base') + , cursor = Base.cursor + , color = Base.color; + +/** + * Expose `TAP`. + */ + +exports = module.exports = TAP; + +/** + * Initialize a new `TAP` reporter. + * + * @param {Runner} runner + * @api public + */ + +function TAP(runner) { + Base.call(this, runner); + + var self = this + , stats = this.stats + , n = 1; + + runner.on('start', function(){ + var total = runner.grepTotal(runner.suite); + console.log('%d..%d', 1, total); + }); + + runner.on('test end', function(){ + ++n; + }); + + runner.on('pending', function(test){ + console.log('ok %d %s # SKIP -', n, title(test)); + }); + + runner.on('pass', function(test){ + console.log('ok %d %s', n, title(test)); + }); + + runner.on('fail', function(test, err){ + console.log('not ok %d %s', n, title(test)); + console.log(err.stack.replace(/^/gm, ' ')); + }); +} + +/** + * Return a TAP-safe title of `test` + * + * @param {Object} test + * @return {String} + * @api private + */ + +function title(test) { + return test.fullTitle().replace(/#/g, ''); +} + +}); // module: reporters/tap.js + +require.register("reporters/teamcity.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base'); + +/** + * Expose `Teamcity`. + */ + +exports = module.exports = Teamcity; + +/** + * Initialize a new `Teamcity` reporter. + * + * @param {Runner} runner + * @api public + */ + +function Teamcity(runner) { + Base.call(this, runner); + var stats = this.stats; + + runner.on('start', function() { + console.log("##teamcity[testSuiteStarted name='mocha.suite']"); + }); + + runner.on('test', function(test) { + console.log("##teamcity[testStarted name='" + escape(test.fullTitle()) + "']"); + }); + + runner.on('fail', function(test, err) { + console.log("##teamcity[testFailed name='" + escape(test.fullTitle()) + "' message='" + escape(err.message) + "']"); + }); + + runner.on('pending', function(test) { + console.log("##teamcity[testIgnored name='" + escape(test.fullTitle()) + "' message='pending']"); + }); + + runner.on('test end', function(test) { + console.log("##teamcity[testFinished name='" + escape(test.fullTitle()) + "' duration='" + test.duration + "']"); + }); + + runner.on('end', function() { + console.log("##teamcity[testSuiteFinished name='mocha.suite' duration='" + stats.duration + "']"); + }); +} + +/** + * Escape the given `str`. + */ + +function escape(str) { + return str + .replace(/\|/g, "||") + .replace(/\n/g, "|n") + .replace(/\r/g, "|r") + .replace(/\[/g, "|[") + .replace(/\]/g, "|]") + .replace(/\u0085/g, "|x") + .replace(/\u2028/g, "|l") + .replace(/\u2029/g, "|p") + .replace(/'/g, "|'"); +} + +}); // module: reporters/teamcity.js + +require.register("reporters/xunit.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Base = require('./base') + , utils = require('../utils') + , escape = utils.escape; + +/** + * Save timer references to avoid Sinon interfering (see GH-237). + */ + +var Date = global.Date + , setTimeout = global.setTimeout + , setInterval = global.setInterval + , clearTimeout = global.clearTimeout + , clearInterval = global.clearInterval; + +/** + * Expose `XUnit`. + */ + +exports = module.exports = XUnit; + +/** + * Initialize a new `XUnit` reporter. + * + * @param {Runner} runner + * @api public + */ + +function XUnit(runner) { + Base.call(this, runner); + var stats = this.stats + , tests = [] + , self = this; + + runner.on('pass', function(test){ + tests.push(test); + }); + + runner.on('fail', function(test){ + tests.push(test); + }); + + runner.on('end', function(){ + console.log(tag('testsuite', { + name: 'Mocha Tests' + , tests: stats.tests + , failures: stats.failures + , errors: stats.failures + , skip: stats.tests - stats.failures - stats.passes + , timestamp: (new Date).toUTCString() + , time: stats.duration / 1000 + }, false)); + + tests.forEach(test); + console.log('</testsuite>'); + }); +} + +/** + * Inherit from `Base.prototype`. + */ + +XUnit.prototype = new Base; +XUnit.prototype.constructor = XUnit; + + +/** + * Output tag for the given `test.` + */ + +function test(test) { + var attrs = { + classname: test.parent.fullTitle() + , name: test.title + , time: test.duration / 1000 + }; + + if ('failed' == test.state) { + var err = test.err; + attrs.message = escape(err.message); + console.log(tag('testcase', attrs, false, tag('failure', attrs, false, cdata(err.stack)))); + } else if (test.pending) { + console.log(tag('testcase', attrs, false, tag('skipped', {}, true))); + } else { + console.log(tag('testcase', attrs, true) ); + } +} + +/** + * HTML tag helper. + */ + +function tag(name, attrs, close, content) { + var end = close ? '/>' : '>' + , pairs = [] + , tag; + + for (var key in attrs) { + pairs.push(key + '="' + escape(attrs[key]) + '"'); + } + + tag = '<' + name + (pairs.length ? ' ' + pairs.join(' ') : '') + end; + if (content) tag += content + '</' + name + end; + return tag; +} + +/** + * Return cdata escaped CDATA `str`. + */ + +function cdata(str) { + return '<![CDATA[' + escape(str) + ']]>'; +} + +}); // module: reporters/xunit.js + +require.register("runnable.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var EventEmitter = require('browser/events').EventEmitter + , debug = require('browser/debug')('mocha:runnable'); + +/** + * Save timer references to avoid Sinon interfering (see GH-237). + */ + +var Date = global.Date + , setTimeout = global.setTimeout + , setInterval = global.setInterval + , clearTimeout = global.clearTimeout + , clearInterval = global.clearInterval; + +/** + * Expose `Runnable`. + */ + +module.exports = Runnable; + +/** + * Initialize a new `Runnable` with the given `title` and callback `fn`. + * + * @param {String} title + * @param {Function} fn + * @api private + */ + +function Runnable(title, fn) { + this.title = title; + this.fn = fn; + this.async = fn && fn.length; + this.sync = ! this.async; + this._timeout = 2000; + this._slow = 75; + this.timedOut = false; +} + +/** + * Inherit from `EventEmitter.prototype`. + */ + +Runnable.prototype = new EventEmitter; +Runnable.prototype.constructor = Runnable; + + +/** + * Set & get timeout `ms`. + * + * @param {Number} ms + * @return {Runnable|Number} ms or self + * @api private + */ + +Runnable.prototype.timeout = function(ms){ + if (0 == arguments.length) return this._timeout; + debug('timeout %d', ms); + this._timeout = ms; + if (this.timer) this.resetTimeout(); + return this; +}; + +/** + * Set & get slow `ms`. + * + * @param {Number} ms + * @return {Runnable|Number} ms or self + * @api private + */ + +Runnable.prototype.slow = function(ms){ + if (0 === arguments.length) return this._slow; + debug('timeout %d', ms); + this._slow = ms; + return this; +}; + +/** + * Return the full title generated by recursively + * concatenating the parent's full title. + * + * @return {String} + * @api public + */ + +Runnable.prototype.fullTitle = function(){ + return this.parent.fullTitle() + ' ' + this.title; +}; + +/** + * Clear the timeout. + * + * @api private + */ + +Runnable.prototype.clearTimeout = function(){ + clearTimeout(this.timer); +}; + +/** + * Inspect the runnable void of private properties. + * + * @return {String} + * @api private + */ + +Runnable.prototype.inspect = function(){ + return JSON.stringify(this, function(key, val){ + if ('_' == key[0]) return; + if ('parent' == key) return '#<Suite>'; + if ('ctx' == key) return '#<Context>'; + return val; + }, 2); +}; + +/** + * Reset the timeout. + * + * @api private + */ + +Runnable.prototype.resetTimeout = function(){ + var self = this + , ms = this.timeout(); + + this.clearTimeout(); + if (ms) { + this.timer = setTimeout(function(){ + self.callback(new Error('timeout of ' + ms + 'ms exceeded')); + self.timedOut = true; + }, ms); + } +}; + +/** + * Run the test and invoke `fn(err)`. + * + * @param {Function} fn + * @api private + */ + +Runnable.prototype.run = function(fn){ + var self = this + , ms = this.timeout() + , start = new Date + , ctx = this.ctx + , finished + , emitted; + + if (ctx) ctx.runnable(this); + + // timeout + if (this.async) { + if (ms) { + this.timer = setTimeout(function(){ + done(new Error('timeout of ' + ms + 'ms exceeded')); + self.timedOut = true; + }, ms); + } + } + + // called multiple times + function multiple(err) { + if (emitted) return; + emitted = true; + self.emit('error', err || new Error('done() called multiple times')); + } + + // finished + function done(err) { + if (self.timedOut) return; + if (finished) return multiple(err); + self.clearTimeout(); + self.duration = new Date - start; + finished = true; + fn(err); + } + + // for .resetTimeout() + this.callback = done; + + // async + if (this.async) { + try { + this.fn.call(ctx, function(err){ + if (err instanceof Error) return done(err); + if (null != err) return done(new Error('done() invoked with non-Error: ' + err)); + done(); + }); + } catch (err) { + done(err); + } + return; + } + + // sync + try { + if (!this.pending) this.fn.call(ctx); + this.duration = new Date - start; + fn(); + } catch (err) { + fn(err); + } +}; + +}); // module: runnable.js + +require.register("runner.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var EventEmitter = require('browser/events').EventEmitter + , debug = require('browser/debug')('mocha:runner') + , Test = require('./test') + , utils = require('./utils') + , filter = utils.filter + , keys = utils.keys + , noop = function(){}; + +/** + * Expose `Runner`. + */ + +module.exports = Runner; + +/** + * Initialize a `Runner` for the given `suite`. + * + * Events: + * + * - `start` execution started + * - `end` execution complete + * - `suite` (suite) test suite execution started + * - `suite end` (suite) all tests (and sub-suites) have finished + * - `test` (test) test execution started + * - `test end` (test) test completed + * - `hook` (hook) hook execution started + * - `hook end` (hook) hook complete + * - `pass` (test) test passed + * - `fail` (test, err) test failed + * + * @api public + */ + +function Runner(suite) { + var self = this; + this._globals = []; + this.suite = suite; + this.total = suite.total(); + this.failures = 0; + this.on('test end', function(test){ self.checkGlobals(test); }); + this.on('hook end', function(hook){ self.checkGlobals(hook); }); + this.grep(/.*/); + this.globals(utils.keys(global).concat(['errno'])); +} + +/** + * Inherit from `EventEmitter.prototype`. + */ + +Runner.prototype = new EventEmitter; +Runner.prototype.constructor = Runner; + + +/** + * Run tests with full titles matching `re`. Updates runner.total + * with number of tests matched. + * + * @param {RegExp} re + * @param {Boolean} invert + * @return {Runner} for chaining + * @api public + */ + +Runner.prototype.grep = function(re, invert){ + debug('grep %s', re); + this._grep = re; + this._invert = invert; + this.total = this.grepTotal(this.suite); + return this; +}; + +/** + * Returns the number of tests matching the grep search for the + * given suite. + * + * @param {Suite} suite + * @return {Number} + * @api public + */ + +Runner.prototype.grepTotal = function(suite) { + var self = this; + var total = 0; + + suite.eachTest(function(test){ + var match = self._grep.test(test.fullTitle()); + if (self._invert) match = !match; + if (match) total++; + }); + + return total; +}; + +/** + * Allow the given `arr` of globals. + * + * @param {Array} arr + * @return {Runner} for chaining + * @api public + */ + +Runner.prototype.globals = function(arr){ + if (0 == arguments.length) return this._globals; + debug('globals %j', arr); + utils.forEach(arr, function(arr){ + this._globals.push(arr); + }, this); + return this; +}; + +/** + * Check for global variable leaks. + * + * @api private + */ + +Runner.prototype.checkGlobals = function(test){ + if (this.ignoreLeaks) return; + var ok = this._globals; + var globals = keys(global); + var isNode = process.kill; + var leaks; + + // check length - 2 ('errno' and 'location' globals) + if (isNode && 1 == ok.length - globals.length) return + else if (2 == ok.length - globals.length) return; + + leaks = filterLeaks(ok, globals); + this._globals = this._globals.concat(leaks); + + if (leaks.length > 1) { + this.fail(test, new Error('global leaks detected: ' + leaks.join(', ') + '')); + } else if (leaks.length) { + this.fail(test, new Error('global leak detected: ' + leaks[0])); + } +}; + +/** + * Fail the given `test`. + * + * @param {Test} test + * @param {Error} err + * @api private + */ + +Runner.prototype.fail = function(test, err){ + ++this.failures; + test.state = 'failed'; + if ('string' == typeof err) { + err = new Error('the string "' + err + '" was thrown, throw an Error :)'); + } + this.emit('fail', test, err); +}; + +/** + * Fail the given `hook` with `err`. + * + * Hook failures (currently) hard-end due + * to that fact that a failing hook will + * surely cause subsequent tests to fail, + * causing jumbled reporting. + * + * @param {Hook} hook + * @param {Error} err + * @api private + */ + +Runner.prototype.failHook = function(hook, err){ + this.fail(hook, err); + this.emit('end'); +}; + +/** + * Run hook `name` callbacks and then invoke `fn()`. + * + * @param {String} name + * @param {Function} function + * @api private + */ + +Runner.prototype.hook = function(name, fn){ + var suite = this.suite + , hooks = suite['_' + name] + , self = this + , timer; + + function next(i) { + var hook = hooks[i]; + if (!hook) return fn(); + self.currentRunnable = hook; + + self.emit('hook', hook); + + hook.on('error', function(err){ + self.failHook(hook, err); + }); + + hook.run(function(err){ + hook.removeAllListeners('error'); + var testError = hook.error(); + if (testError) self.fail(self.test, testError); + if (err) return self.failHook(hook, err); + self.emit('hook end', hook); + next(++i); + }); + } + + process.nextTick(function(){ + next(0); + }); +}; + +/** + * Run hook `name` for the given array of `suites` + * in order, and callback `fn(err)`. + * + * @param {String} name + * @param {Array} suites + * @param {Function} fn + * @api private + */ + +Runner.prototype.hooks = function(name, suites, fn){ + var self = this + , orig = this.suite; + + function next(suite) { + self.suite = suite; + + if (!suite) { + self.suite = orig; + return fn(); + } + + self.hook(name, function(err){ + if (err) { + self.suite = orig; + return fn(err); + } + + next(suites.pop()); + }); + } + + next(suites.pop()); +}; + +/** + * Run hooks from the top level down. + * + * @param {String} name + * @param {Function} fn + * @api private + */ + +Runner.prototype.hookUp = function(name, fn){ + var suites = [this.suite].concat(this.parents()).reverse(); + this.hooks(name, suites, fn); +}; + +/** + * Run hooks from the bottom up. + * + * @param {String} name + * @param {Function} fn + * @api private + */ + +Runner.prototype.hookDown = function(name, fn){ + var suites = [this.suite].concat(this.parents()); + this.hooks(name, suites, fn); +}; + +/** + * Return an array of parent Suites from + * closest to furthest. + * + * @return {Array} + * @api private + */ + +Runner.prototype.parents = function(){ + var suite = this.suite + , suites = []; + while (suite = suite.parent) suites.push(suite); + return suites; +}; + +/** + * Run the current test and callback `fn(err)`. + * + * @param {Function} fn + * @api private + */ + +Runner.prototype.runTest = function(fn){ + var test = this.test + , self = this; + + try { + test.on('error', function(err){ + self.fail(test, err); + }); + test.run(fn); + } catch (err) { + fn(err); + } +}; + +/** + * Run tests in the given `suite` and invoke + * the callback `fn()` when complete. + * + * @param {Suite} suite + * @param {Function} fn + * @api private + */ + +Runner.prototype.runTests = function(suite, fn){ + var self = this + , tests = suite.tests + , test; + + function next(err) { + // if we bail after first err + if (self.failures && suite._bail) return fn(); + + // next test + test = tests.shift(); + + // all done + if (!test) return fn(); + + // grep + var match = self._grep.test(test.fullTitle()); + if (self._invert) match = !match; + if (!match) return next(); + + // pending + if (test.pending) { + self.emit('pending', test); + self.emit('test end', test); + return next(); + } + + // execute test and hook(s) + self.emit('test', self.test = test); + self.hookDown('beforeEach', function(){ + self.currentRunnable = self.test; + self.runTest(function(err){ + test = self.test; + + if (err) { + self.fail(test, err); + self.emit('test end', test); + return self.hookUp('afterEach', next); + } + + test.state = 'passed'; + self.emit('pass', test); + self.emit('test end', test); + self.hookUp('afterEach', next); + }); + }); + } + + this.next = next; + next(); +}; + +/** + * Run the given `suite` and invoke the + * callback `fn()` when complete. + * + * @param {Suite} suite + * @param {Function} fn + * @api private + */ + +Runner.prototype.runSuite = function(suite, fn){ + var total = this.grepTotal(suite) + , self = this + , i = 0; + + debug('run suite %s', suite.fullTitle()); + + if (!total) return fn(); + + this.emit('suite', this.suite = suite); + + function next() { + var curr = suite.suites[i++]; + if (!curr) return done(); + self.runSuite(curr, next); + } + + function done() { + self.suite = suite; + self.hook('afterAll', function(){ + self.emit('suite end', suite); + fn(); + }); + } + + this.hook('beforeAll', function(){ + self.runTests(suite, next); + }); +}; + +/** + * Handle uncaught exceptions. + * + * @param {Error} err + * @api private + */ + +Runner.prototype.uncaught = function(err){ + debug('uncaught exception %s', err.message); + var runnable = this.currentRunnable; + if (!runnable || 'failed' == runnable.state) return; + runnable.clearTimeout(); + err.uncaught = true; + this.fail(runnable, err); + + // recover from test + if ('test' == runnable.type) { + this.emit('test end', runnable); + this.hookUp('afterEach', this.next); + return; + } + + // bail on hooks + this.emit('end'); +}; + +/** + * Run the root suite and invoke `fn(failures)` + * on completion. + * + * @param {Function} fn + * @return {Runner} for chaining + * @api public + */ + +Runner.prototype.run = function(fn){ + var self = this + , fn = fn || function(){}; + + debug('start'); + + // uncaught callback + function uncaught(err) { + self.uncaught(err); + } + + // callback + this.on('end', function(){ + debug('end'); + process.removeListener('uncaughtException', uncaught); + fn(self.failures); + }); + + // run suites + this.emit('start'); + this.runSuite(this.suite, function(){ + debug('finished running'); + self.emit('end'); + }); + + // uncaught exception + process.on('uncaughtException', uncaught); + + return this; +}; + +/** + * Filter leaks with the given globals flagged as `ok`. + * + * @param {Array} ok + * @param {Array} globals + * @return {Array} + * @api private + */ + +function filterLeaks(ok, globals) { + return filter(globals, function(key){ + var matched = filter(ok, function(ok){ + if (~ok.indexOf('*')) return 0 == key.indexOf(ok.split('*')[0]); + return key == ok; + }); + return matched.length == 0 && (!global.navigator || 'onerror' !== key); + }); +} + +}); // module: runner.js + +require.register("suite.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var EventEmitter = require('browser/events').EventEmitter + , debug = require('browser/debug')('mocha:suite') + , milliseconds = require('./ms') + , utils = require('./utils') + , Hook = require('./hook'); + +/** + * Expose `Suite`. + */ + +exports = module.exports = Suite; + +/** + * Create a new `Suite` with the given `title` + * and parent `Suite`. When a suite with the + * same title is already present, that suite + * is returned to provide nicer reporter + * and more flexible meta-testing. + * + * @param {Suite} parent + * @param {String} title + * @return {Suite} + * @api public + */ + +exports.create = function(parent, title){ + var suite = new Suite(title, parent.ctx); + suite.parent = parent; + if (parent.pending) suite.pending = true; + title = suite.fullTitle(); + parent.addSuite(suite); + return suite; +}; + +/** + * Initialize a new `Suite` with the given + * `title` and `ctx`. + * + * @param {String} title + * @param {Context} ctx + * @api private + */ + +function Suite(title, ctx) { + this.title = title; + this.ctx = ctx; + this.suites = []; + this.tests = []; + this.pending = false; + this._beforeEach = []; + this._beforeAll = []; + this._afterEach = []; + this._afterAll = []; + this.root = !title; + this._timeout = 2000; + this._slow = 75; + this._bail = false; +} + +/** + * Inherit from `EventEmitter.prototype`. + */ + +Suite.prototype = new EventEmitter; +Suite.prototype.constructor = Suite; + + +/** + * Return a clone of this `Suite`. + * + * @return {Suite} + * @api private + */ + +Suite.prototype.clone = function(){ + var suite = new Suite(this.title); + debug('clone'); + suite.ctx = this.ctx; + suite.timeout(this.timeout()); + suite.slow(this.slow()); + suite.bail(this.bail()); + return suite; +}; + +/** + * Set timeout `ms` or short-hand such as "2s". + * + * @param {Number|String} ms + * @return {Suite|Number} for chaining + * @api private + */ + +Suite.prototype.timeout = function(ms){ + if (0 == arguments.length) return this._timeout; + if ('string' == typeof ms) ms = milliseconds(ms); + debug('timeout %d', ms); + this._timeout = parseInt(ms, 10); + return this; +}; + +/** + * Set slow `ms` or short-hand such as "2s". + * + * @param {Number|String} ms + * @return {Suite|Number} for chaining + * @api private + */ + +Suite.prototype.slow = function(ms){ + if (0 === arguments.length) return this._slow; + if ('string' == typeof ms) ms = milliseconds(ms); + debug('slow %d', ms); + this._slow = ms; + return this; +}; + +/** + * Sets whether to bail after first error. + * + * @parma {Boolean} bail + * @return {Suite|Number} for chaining + * @api private + */ + +Suite.prototype.bail = function(bail){ + if (0 == arguments.length) return this._bail; + debug('bail %s', bail); + this._bail = bail; + return this; +}; + +/** + * Run `fn(test[, done])` before running tests. + * + * @param {Function} fn + * @return {Suite} for chaining + * @api private + */ + +Suite.prototype.beforeAll = function(fn){ + if (this.pending) return this; + var hook = new Hook('"before all" hook', fn); + hook.parent = this; + hook.timeout(this.timeout()); + hook.slow(this.slow()); + hook.ctx = this.ctx; + this._beforeAll.push(hook); + this.emit('beforeAll', hook); + return this; +}; + +/** + * Run `fn(test[, done])` after running tests. + * + * @param {Function} fn + * @return {Suite} for chaining + * @api private + */ + +Suite.prototype.afterAll = function(fn){ + if (this.pending) return this; + var hook = new Hook('"after all" hook', fn); + hook.parent = this; + hook.timeout(this.timeout()); + hook.slow(this.slow()); + hook.ctx = this.ctx; + this._afterAll.push(hook); + this.emit('afterAll', hook); + return this; +}; + +/** + * Run `fn(test[, done])` before each test case. + * + * @param {Function} fn + * @return {Suite} for chaining + * @api private + */ + +Suite.prototype.beforeEach = function(fn){ + if (this.pending) return this; + var hook = new Hook('"before each" hook', fn); + hook.parent = this; + hook.timeout(this.timeout()); + hook.slow(this.slow()); + hook.ctx = this.ctx; + this._beforeEach.push(hook); + this.emit('beforeEach', hook); + return this; +}; + +/** + * Run `fn(test[, done])` after each test case. + * + * @param {Function} fn + * @return {Suite} for chaining + * @api private + */ + +Suite.prototype.afterEach = function(fn){ + if (this.pending) return this; + var hook = new Hook('"after each" hook', fn); + hook.parent = this; + hook.timeout(this.timeout()); + hook.slow(this.slow()); + hook.ctx = this.ctx; + this._afterEach.push(hook); + this.emit('afterEach', hook); + return this; +}; + +/** + * Add a test `suite`. + * + * @param {Suite} suite + * @return {Suite} for chaining + * @api private + */ + +Suite.prototype.addSuite = function(suite){ + suite.parent = this; + suite.timeout(this.timeout()); + suite.slow(this.slow()); + suite.bail(this.bail()); + this.suites.push(suite); + this.emit('suite', suite); + return this; +}; + +/** + * Add a `test` to this suite. + * + * @param {Test} test + * @return {Suite} for chaining + * @api private + */ + +Suite.prototype.addTest = function(test){ + test.parent = this; + test.timeout(this.timeout()); + test.slow(this.slow()); + test.ctx = this.ctx; + this.tests.push(test); + this.emit('test', test); + return this; +}; + +/** + * Return the full title generated by recursively + * concatenating the parent's full title. + * + * @return {String} + * @api public + */ + +Suite.prototype.fullTitle = function(){ + if (this.parent) { + var full = this.parent.fullTitle(); + if (full) return full + ' ' + this.title; + } + return this.title; +}; + +/** + * Return the total number of tests. + * + * @return {Number} + * @api public + */ + +Suite.prototype.total = function(){ + return utils.reduce(this.suites, function(sum, suite){ + return sum + suite.total(); + }, 0) + this.tests.length; +}; + +/** + * Iterates through each suite recursively to find + * all tests. Applies a function in the format + * `fn(test)`. + * + * @param {Function} fn + * @return {Suite} + * @api private + */ + +Suite.prototype.eachTest = function(fn){ + utils.forEach(this.tests, fn); + utils.forEach(this.suites, function(suite){ + suite.eachTest(fn); + }); + return this; +}; + +}); // module: suite.js + +require.register("test.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var Runnable = require('./runnable'); + +/** + * Expose `Test`. + */ + +module.exports = Test; + +/** + * Initialize a new `Test` with the given `title` and callback `fn`. + * + * @param {String} title + * @param {Function} fn + * @api private + */ + +function Test(title, fn) { + Runnable.call(this, title, fn); + this.pending = !fn; + this.type = 'test'; +} + +/** + * Inherit from `Runnable.prototype`. + */ + +Test.prototype = new Runnable; +Test.prototype.constructor = Test; + + +}); // module: test.js + +require.register("utils.js", function(module, exports, require){ + +/** + * Module dependencies. + */ + +var fs = require('browser/fs') + , path = require('browser/path') + , join = path.join + , debug = require('browser/debug')('mocha:watch'); + +/** + * Ignored directories. + */ + +var ignore = ['node_modules', '.git']; + +/** + * Escape special characters in the given string of html. + * + * @param {String} html + * @return {String} + * @api private + */ + +exports.escape = function(html){ + return String(html) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>'); +}; + +/** + * Array#forEach (<=IE8) + * + * @param {Array} array + * @param {Function} fn + * @param {Object} scope + * @api private + */ + +exports.forEach = function(arr, fn, scope){ + for (var i = 0, l = arr.length; i < l; i++) + fn.call(scope, arr[i], i); +}; + +/** + * Array#indexOf (<=IE8) + * + * @parma {Array} arr + * @param {Object} obj to find index of + * @param {Number} start + * @api private + */ + +exports.indexOf = function(arr, obj, start){ + for (var i = start || 0, l = arr.length; i < l; i++) { + if (arr[i] === obj) + return i; + } + return -1; +}; + +/** + * Array#reduce (<=IE8) + * + * @param {Array} array + * @param {Function} fn + * @param {Object} initial value + * @api private + */ + +exports.reduce = function(arr, fn, val){ + var rval = val; + + for (var i = 0, l = arr.length; i < l; i++) { + rval = fn(rval, arr[i], i, arr); + } + + return rval; +}; + +/** + * Array#filter (<=IE8) + * + * @param {Array} array + * @param {Function} fn + * @api private + */ + +exports.filter = function(arr, fn){ + var ret = []; + + for (var i = 0, l = arr.length; i < l; i++) { + var val = arr[i]; + if (fn(val, i, arr)) ret.push(val); + } + + return ret; +}; + +/** + * Object.keys (<=IE8) + * + * @param {Object} obj + * @return {Array} keys + * @api private + */ + +exports.keys = Object.keys || function(obj) { + var keys = [] + , has = Object.prototype.hasOwnProperty // for `window` on <=IE8 + + for (var key in obj) { + if (has.call(obj, key)) { + keys.push(key); + } + } + + return keys; +}; + +/** + * Watch the given `files` for changes + * and invoke `fn(file)` on modification. + * + * @param {Array} files + * @param {Function} fn + * @api private + */ + +exports.watch = function(files, fn){ + var options = { interval: 100 }; + files.forEach(function(file){ + debug('file %s', file); + fs.watchFile(file, options, function(curr, prev){ + if (prev.mtime < curr.mtime) fn(file); + }); + }); +}; + +/** + * Ignored files. + */ + +function ignored(path){ + return !~ignore.indexOf(path); +} + +/** + * Lookup files in the given `dir`. + * + * @return {Array} + * @api private + */ + +exports.files = function(dir, ret){ + ret = ret || []; + + fs.readdirSync(dir) + .filter(ignored) + .forEach(function(path){ + path = join(dir, path); + if (fs.statSync(path).isDirectory()) { + exports.files(path, ret); + } else if (path.match(/\.(js|coffee)$/)) { + ret.push(path); + } + }); + + return ret; +}; + +/** + * Compute a slug from the given `str`. + * + * @param {String} str + * @return {String} + * @api private + */ + +exports.slug = function(str){ + return str + .toLowerCase() + .replace(/ +/g, '-') + .replace(/[^-\w]/g, ''); +}; + +/** + * Strip the function definition from `str`, + * and re-indent for pre whitespace. + */ + +exports.clean = function(str) { + str = str + .replace(/^function *\(.*\) *{/, '') + .replace(/\s+\}$/, ''); + + var spaces = str.match(/^\n?( *)/)[1].length + , re = new RegExp('^ {' + spaces + '}', 'gm'); + + str = str.replace(re, ''); + + return exports.trim(str); +}; + +/** + * Escape regular expression characters in `str`. + * + * @param {String} str + * @return {String} + * @api private + */ + +exports.escapeRegexp = function(str){ + return str.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"); +}; + +/** + * Trim the given `str`. + * + * @param {String} str + * @return {String} + * @api private + */ + +exports.trim = function(str){ + return str.replace(/^\s+|\s+$/g, ''); +}; + +/** + * Parse the given `qs`. + * + * @param {String} qs + * @return {Object} + * @api private + */ + +exports.parseQuery = function(qs){ + return exports.reduce(qs.replace('?', '').split('&'), function(obj, pair){ + var i = pair.indexOf('=') + , key = pair.slice(0, i) + , val = pair.slice(++i); + + obj[key] = decodeURIComponent(val); + return obj; + }, {}); +}; + +/** + * Highlight the given string of `js`. + * + * @param {String} js + * @return {String} + * @api private + */ + +function highlight(js) { + return js + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/\/\/(.*)/gm, '<span class="comment">//$1</span>') + .replace(/('.*?')/gm, '<span class="string">$1</span>') + .replace(/(\d+\.\d+)/gm, '<span class="number">$1</span>') + .replace(/(\d+)/gm, '<span class="number">$1</span>') + .replace(/\bnew *(\w+)/gm, '<span class="keyword">new</span> <span class="init">$1</span>') + .replace(/\b(function|new|throw|return|var|if|else)\b/gm, '<span class="keyword">$1</span>') +} + +/** + * Highlight the contents of tag `name`. + * + * @param {String} name + * @api private + */ + +exports.highlightTags = function(name) { + var code = document.getElementsByTagName(name); + for (var i = 0, len = code.length; i < len; ++i) { + code[i].innerHTML = highlight(code[i].innerHTML); + } +}; + +}); // module: utils.js +/** + * Node shims. + * + * These are meant only to allow + * mocha.js to run untouched, not + * to allow running node code in + * the browser. + */ + +process = {}; +process.exit = function(status){}; +process.stdout = {}; +global = window; + +/** + * next tick implementation. + */ + +process.nextTick = (function(){ + // postMessage behaves badly on IE8 + if (window.ActiveXObject || !window.postMessage) { + return function(fn){ fn() }; + } + + // based on setZeroTimeout by David Baron + // - http://dbaron.org/log/20100309-faster-timeouts + var timeouts = [] + , name = 'mocha-zero-timeout' + + window.addEventListener('message', function(e){ + if (e.source == window && e.data == name) { + if (e.stopPropagation) e.stopPropagation(); + if (timeouts.length) timeouts.shift()(); + } + }, true); + + return function(fn){ + timeouts.push(fn); + window.postMessage(name, '*'); + } +})(); + +/** + * Remove uncaughtException listener. + */ + +process.removeListener = function(e){ + if ('uncaughtException' == e) { + window.onerror = null; + } +}; + +/** + * Implements uncaughtException listener. + */ + +process.on = function(e, fn){ + if ('uncaughtException' == e) { + window.onerror = fn; + } +}; + +// boot +;(function(){ + + /** + * Expose mocha. + */ + + var Mocha = window.Mocha = require('mocha'), + mocha = window.mocha = new Mocha({ reporter: 'html' }); + + /** + * Override ui to ensure that the ui functions are initialized. + * Normally this would happen in Mocha.prototype.loadFiles. + */ + + mocha.ui = function(ui){ + Mocha.prototype.ui.call(this, ui); + this.suite.emit('pre-require', window, null, this); + return this; + }; + + /** + * Setup mocha with the given setting options. + */ + + mocha.setup = function(opts){ + if ('string' == typeof opts) opts = { ui: opts }; + for (var opt in opts) this[opt](opts[opt]); + return this; + }; + + /** + * Run mocha, returning the Runner. + */ + + mocha.run = function(fn){ + var options = mocha.options; + mocha.globals('location'); + + //var query = Mocha.utils.parseQuery(window.location.search || ''); + //if (query.grep) mocha.grep(query.grep); + + return Mocha.prototype.run.call(mocha, function(){ + Mocha.utils.highlightTags('code'); + if (fn) fn(); + }); + }; +})(); +})(); \ No newline at end of file diff --git a/tests/frontend/lib/sendkeys.js b/tests/frontend/lib/sendkeys.js new file mode 100644 index 00000000..9170fe7e --- /dev/null +++ b/tests/frontend/lib/sendkeys.js @@ -0,0 +1,467 @@ +// Cross-broswer implementation of text ranges and selections +// documentation: http://bililite.com/blog/2011/01/11/cross-browser-.and-selections/ +// Version: 1.1 +// Copyright (c) 2010 Daniel Wachsstock +// MIT license: +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: + +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +(function($){ + +bililiteRange = function(el, debug){ + var ret; + if (debug){ + ret = new NothingRange(); // Easier to force it to use the no-selection type than to try to find an old browser + }else if (document.selection && !document.addEventListener){ + // Internet Explorer 8 and lower + ret = new IERange(); + }else if (window.getSelection && el.setSelectionRange){ + // Standards. Element is an input or textarea + ret = new InputRange(); + }else if (window.getSelection){ + // Standards, with any other kind of element + ret = new W3CRange() + }else{ + // doesn't support selection + ret = new NothingRange(); + } + ret._el = el; + ret._doc = el.ownerDocument; + ret._win = 'defaultView' in ret._doc ? ret._doc.defaultView : ret._doc.parentWindow; + ret._textProp = textProp(el); + ret._bounds = [0, ret.length()]; + return ret; +} + +function textProp(el){ + // returns the property that contains the text of the element + if (typeof el.value != 'undefined') return 'value'; + if (typeof el.text != 'undefined') return 'text'; + if (typeof el.textContent != 'undefined') return 'textContent'; + return 'innerText'; +} + +// base class +function Range(){} +Range.prototype = { + length: function() { + return this._el[this._textProp].replace(/\r/g, '').length; // need to correct for IE's CrLf weirdness + }, + bounds: function(s){ + if (s === 'all'){ + this._bounds = [0, this.length()]; + }else if (s === 'start'){ + this._bounds = [0, 0]; + }else if (s === 'end'){ + this._bounds = [this.length(), this.length()]; + }else if (s === 'selection'){ + this.bounds ('all'); // first select the whole thing for constraining + this._bounds = this._nativeSelection(); + }else if (s){ + this._bounds = s; // don't error check now; the element may change at any moment, so constrain it when we need it. + }else{ + var b = [ + Math.max(0, Math.min (this.length(), this._bounds[0])), + Math.max(0, Math.min (this.length(), this._bounds[1])) + ]; + return b; // need to constrain it to fit + } + return this; // allow for chaining + }, + select: function(){ + this._nativeSelect(this._nativeRange(this.bounds())); + return this; // allow for chaining + }, + text: function(text, select){ + if (arguments.length){ + this._nativeSetText(text, this._nativeRange(this.bounds())); + if (select == 'start'){ + this.bounds ([this._bounds[0], this._bounds[0]]); + this.select(); + }else if (select == 'end'){ + this.bounds ([this._bounds[0]+text.length, this._bounds[0]+text.length]); + this.select(); + }else if (select == 'all'){ + this.bounds ([this._bounds[0], this._bounds[0]+text.length]); + this.select(); + } + return this; // allow for chaining + }else{ + return this._nativeGetText(this._nativeRange(this.bounds())); + } + }, + insertEOL: function (){ + this._nativeEOL(); + this._bounds = [this._bounds[0]+1, this._bounds[0]+1]; // move past the EOL marker + return this; + } +}; + + +function IERange(){} +IERange.prototype = new Range(); +IERange.prototype._nativeRange = function (bounds){ + var rng; + if (this._el.tagName == 'INPUT'){ + // IE 8 is very inconsistent; textareas have createTextRange but it doesn't work + rng = this._el.createTextRange(); + }else{ + rng = this._doc.body.createTextRange (); + rng.moveToElementText(this._el); + } + if (bounds){ + if (bounds[1] < 0) bounds[1] = 0; // IE tends to run elements out of bounds + if (bounds[0] > this.length()) bounds[0] = this.length(); + if (bounds[1] < rng.text.replace(/\r/g, '').length){ // correct for IE's CrLf wierdness + // block-display elements have an invisible, uncounted end of element marker, so we move an extra one and use the current length of the range + rng.moveEnd ('character', -1); + rng.moveEnd ('character', bounds[1]-rng.text.replace(/\r/g, '').length); + } + if (bounds[0] > 0) rng.moveStart('character', bounds[0]); + } + return rng; +}; +IERange.prototype._nativeSelect = function (rng){ + rng.select(); +}; +IERange.prototype._nativeSelection = function (){ + // returns [start, end] for the selection constrained to be in element + var rng = this._nativeRange(); // range of the element to constrain to + var len = this.length(); + if (this._doc.selection.type != 'Text') return [0,0]; // append to the end + var sel = this._doc.selection.createRange(); + try{ + return [ + iestart(sel, rng), + ieend (sel, rng) + ]; + }catch (e){ + // IE gets upset sometimes about comparing text to input elements, but the selections cannot overlap, so make a best guess + return (sel.parentElement().sourceIndex < this._el.sourceIndex) ? [0,0] : [len, len]; + } +}; +IERange.prototype._nativeGetText = function (rng){ + return rng.text.replace(/\r/g, ''); // correct for IE's CrLf weirdness +}; +IERange.prototype._nativeSetText = function (text, rng){ + rng.text = text; +}; +IERange.prototype._nativeEOL = function(){ + if (typeof this._el.value != 'undefined'){ + this.text('\n'); // for input and textarea, insert it straight + }else{ + this._nativeRange(this.bounds()).pasteHTML('<br/>'); + } +}; +// IE internals +function iestart(rng, constraint){ + // returns the position (in character) of the start of rng within constraint. If it's not in constraint, returns 0 if it's before, length if it's after + var len = constraint.text.replace(/\r/g, '').length; // correct for IE's CrLf wierdness + if (rng.compareEndPoints ('StartToStart', constraint) <= 0) return 0; // at or before the beginning + if (rng.compareEndPoints ('StartToEnd', constraint) >= 0) return len; + for (var i = 0; rng.compareEndPoints ('StartToStart', constraint) > 0; ++i, rng.moveStart('character', -1)); + return i; +} +function ieend (rng, constraint){ + // returns the position (in character) of the end of rng within constraint. If it's not in constraint, returns 0 if it's before, length if it's after + var len = constraint.text.replace(/\r/g, '').length; // correct for IE's CrLf wierdness + if (rng.compareEndPoints ('EndToEnd', constraint) >= 0) return len; // at or after the end + if (rng.compareEndPoints ('EndToStart', constraint) <= 0) return 0; + for (var i = 0; rng.compareEndPoints ('EndToStart', constraint) > 0; ++i, rng.moveEnd('character', -1)); + return i; +} + +// an input element in a standards document. "Native Range" is just the bounds array +function InputRange(){} +InputRange.prototype = new Range(); +InputRange.prototype._nativeRange = function(bounds) { + return bounds || [0, this.length()]; +}; +InputRange.prototype._nativeSelect = function (rng){ + this._el.setSelectionRange(rng[0], rng[1]); +}; +InputRange.prototype._nativeSelection = function(){ + return [this._el.selectionStart, this._el.selectionEnd]; +}; +InputRange.prototype._nativeGetText = function(rng){ + return this._el.value.substring(rng[0], rng[1]); +}; +InputRange.prototype._nativeSetText = function(text, rng){ + var val = this._el.value; + this._el.value = val.substring(0, rng[0]) + text + val.substring(rng[1]); +}; +InputRange.prototype._nativeEOL = function(){ + this.text('\n'); +}; + +function W3CRange(){} +W3CRange.prototype = new Range(); +W3CRange.prototype._nativeRange = function (bounds){ + var rng = this._doc.createRange(); + rng.selectNodeContents(this._el); + if (bounds){ + w3cmoveBoundary (rng, bounds[0], true, this._el); + rng.collapse (true); + w3cmoveBoundary (rng, bounds[1]-bounds[0], false, this._el); + } + return rng; +}; +W3CRange.prototype._nativeSelect = function (rng){ + this._win.getSelection().removeAllRanges(); + this._win.getSelection().addRange (rng); +}; +W3CRange.prototype._nativeSelection = function (){ + // returns [start, end] for the selection constrained to be in element + var rng = this._nativeRange(); // range of the element to constrain to + if (this._win.getSelection().rangeCount == 0) return [this.length(), this.length()]; // append to the end + var sel = this._win.getSelection().getRangeAt(0); + return [ + w3cstart(sel, rng), + w3cend (sel, rng) + ]; + } +W3CRange.prototype._nativeGetText = function (rng){ + return rng.toString(); +}; +W3CRange.prototype._nativeSetText = function (text, rng){ + rng.deleteContents(); + rng.insertNode (this._doc.createTextNode(text)); + this._el.normalize(); // merge the text with the surrounding text +}; +W3CRange.prototype._nativeEOL = function(){ + var rng = this._nativeRange(this.bounds()); + rng.deleteContents(); + var br = this._doc.createElement('br'); + br.setAttribute ('_moz_dirty', ''); // for Firefox + rng.insertNode (br); + rng.insertNode (this._doc.createTextNode('\n')); + rng.collapse (false); +}; +// W3C internals +function nextnode (node, root){ + // in-order traversal + // we've already visited node, so get kids then siblings + if (node.firstChild) return node.firstChild; + if (node.nextSibling) return node.nextSibling; + if (node===root) return null; + while (node.parentNode){ + // get uncles + node = node.parentNode; + if (node == root) return null; + if (node.nextSibling) return node.nextSibling; + } + return null; +} +function w3cmoveBoundary (rng, n, bStart, el){ + // move the boundary (bStart == true ? start : end) n characters forward, up to the end of element el. Forward only! + // if the start is moved after the end, then an exception is raised + if (n <= 0) return; + var node = rng[bStart ? 'startContainer' : 'endContainer']; + if (node.nodeType == 3){ + // we may be starting somewhere into the text + n += rng[bStart ? 'startOffset' : 'endOffset']; + } + while (node){ + if (node.nodeType == 3){ + if (n <= node.nodeValue.length){ + rng[bStart ? 'setStart' : 'setEnd'](node, n); + // special case: if we end next to a <br>, include that node. + if (n == node.nodeValue.length){ + // skip past zero-length text nodes + for (var next = nextnode (node, el); next && next.nodeType==3 && next.nodeValue.length == 0; next = nextnode(next, el)){ + rng[bStart ? 'setStartAfter' : 'setEndAfter'](next); + } + if (next && next.nodeType == 1 && next.nodeName == "BR") rng[bStart ? 'setStartAfter' : 'setEndAfter'](next); + } + return; + }else{ + rng[bStart ? 'setStartAfter' : 'setEndAfter'](node); // skip past this one + n -= node.nodeValue.length; // and eat these characters + } + } + node = nextnode (node, el); + } +} +var START_TO_START = 0; // from the w3c definitions +var START_TO_END = 1; +var END_TO_END = 2; +var END_TO_START = 3; +// from the Mozilla documentation, for range.compareBoundaryPoints(how, sourceRange) +// -1, 0, or 1, indicating whether the corresponding boundary-point of range is respectively before, equal to, or after the corresponding boundary-point of sourceRange. + // * Range.END_TO_END compares the end boundary-point of sourceRange to the end boundary-point of range. + // * Range.END_TO_START compares the end boundary-point of sourceRange to the start boundary-point of range. + // * Range.START_TO_END compares the start boundary-point of sourceRange to the end boundary-point of range. + // * Range.START_TO_START compares the start boundary-point of sourceRange to the start boundary-point of range. +function w3cstart(rng, constraint){ + if (rng.compareBoundaryPoints (START_TO_START, constraint) <= 0) return 0; // at or before the beginning + if (rng.compareBoundaryPoints (END_TO_START, constraint) >= 0) return constraint.toString().length; + rng = rng.cloneRange(); // don't change the original + rng.setEnd (constraint.endContainer, constraint.endOffset); // they now end at the same place + return constraint.toString().length - rng.toString().length; +} +function w3cend (rng, constraint){ + if (rng.compareBoundaryPoints (END_TO_END, constraint) >= 0) return constraint.toString().length; // at or after the end + if (rng.compareBoundaryPoints (START_TO_END, constraint) <= 0) return 0; + rng = rng.cloneRange(); // don't change the original + rng.setStart (constraint.startContainer, constraint.startOffset); // they now start at the same place + return rng.toString().length; +} + +function NothingRange(){} +NothingRange.prototype = new Range(); +NothingRange.prototype._nativeRange = function(bounds) { + return bounds || [0,this.length()]; +}; +NothingRange.prototype._nativeSelect = function (rng){ // do nothing +}; +NothingRange.prototype._nativeSelection = function(){ + return [0,0]; +}; +NothingRange.prototype._nativeGetText = function (rng){ + return this._el[this._textProp].substring(rng[0], rng[1]); +}; +NothingRange.prototype._nativeSetText = function (text, rng){ + var val = this._el[this._textProp]; + this._el[this._textProp] = val.substring(0, rng[0]) + text + val.substring(rng[1]); +}; +NothingRange.prototype._nativeEOL = function(){ + this.text('\n'); +}; + +})(jQuery); + +// insert characters in a textarea or text input field +// special characters are enclosed in {}; use {{} for the { character itself +// documentation: http://bililite.com/blog/2008/08/20/the-fnsendkeys-plugin/ +// Version: 2.0 +// Copyright (c) 2010 Daniel Wachsstock +// MIT license: +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: + +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +(function($){ + +$.fn.sendkeys = function (x, opts){ + return this.each( function(){ + var localkeys = $.extend({}, opts, $(this).data('sendkeys')); // allow for element-specific key functions + // most elements to not keep track of their selection when they lose focus, so we have to do it for them + var rng = $.data (this, 'sendkeys.selection'); + if (!rng){ + rng = bililiteRange(this).bounds('selection'); + $.data(this, 'sendkeys.selection', rng); + $(this).bind('mouseup.sendkeys', function(){ + // we have to update the saved range. The routines here update the bounds with each press, but actual keypresses and mouseclicks do not + $.data(this, 'sendkeys.selection').bounds('selection'); + }).bind('keyup.sendkeys', function(evt){ + // restore the selection if we got here with a tab (a click should select what was clicked on) + if (evt.which == 9){ + // there's a flash of selection when we restore the focus, but I don't know how to avoid that. + $.data(this, 'sendkeys.selection').select(); + }else{ + $.data(this, 'sendkeys.selection').bounds('selection'); + } + }); + } + this.focus(); + if (typeof x === 'undefined') return; // no string, so we just set up the event handlers + $.data(this, 'sendkeys.originalText', rng.text()); + x.replace(/\n/g, '{enter}'). // turn line feeds into explicit break insertions + replace(/{[^}]*}|[^{]+/g, function(s){ + (localkeys[s] || $.fn.sendkeys.defaults[s] || $.fn.sendkeys.defaults.simplechar)(rng, s); + }); + $(this).trigger({type: 'sendkeys', which: x}); + }); +}; // sendkeys + + +// add the functions publicly so they can be overridden +$.fn.sendkeys.defaults = { + simplechar: function (rng, s){ + rng.text(s, 'end'); + for (var i =0; i < s.length; ++i){ + var x = s.charCodeAt(i); + // a bit of cheating: rng._el is the element associated with rng. + $(rng._el).trigger({type: 'keypress', keyCode: x, which: x, charCode: x}); + } + }, + '{{}': function (rng){ + $.fn.sendkeys.defaults.simplechar (rng, '{') + }, + '{enter}': function (rng){ + rng.insertEOL(); + rng.select(); + var x = '\n'.charCodeAt(0); + $(rng._el).trigger({type: 'keypress', keyCode: x, which: x, charCode: x}); + }, + '{backspace}': function (rng){ + var b = rng.bounds(); + if (b[0] == b[1]) rng.bounds([b[0]-1, b[0]]); // no characters selected; it's just an insertion point. Remove the previous character + rng.text('', 'end'); // delete the characters and update the selection + }, + '{del}': function (rng){ + var b = rng.bounds(); + if (b[0] == b[1]) rng.bounds([b[0], b[0]+1]); // no characters selected; it's just an insertion point. Remove the next character + rng.text('', 'end'); // delete the characters and update the selection + }, + '{rightarrow}': function (rng){ + var b = rng.bounds(); + if (b[0] == b[1]) ++b[1]; // no characters selected; it's just an insertion point. Move to the right + rng.bounds([b[1], b[1]]).select(); + }, + '{leftarrow}': function (rng){ + var b = rng.bounds(); + if (b[0] == b[1]) --b[0]; // no characters selected; it's just an insertion point. Move to the left + rng.bounds([b[0], b[0]]).select(); + }, + '{selectall}' : function (rng){ + rng.bounds('all').select(); + }, + '{selection}': function (rng){ + $.fn.sendkeys.defaults.simplechar(rng, $.data(rng._el, 'sendkeys.originalText')); + }, + '{mark}' : function (rng){ + var bounds = rng.bounds(); + $(rng._el).one('sendkeys', function(){ + // set up the event listener to change the selection after the sendkeys is done + rng.bounds(bounds).select(); + }); + } +}; + +})(jQuery) diff --git a/tests/frontend/lib/underscore.js b/tests/frontend/lib/underscore.js new file mode 100644 index 00000000..1ebe2671 --- /dev/null +++ b/tests/frontend/lib/underscore.js @@ -0,0 +1,1200 @@ +// Underscore.js 1.4.2 +// http://underscorejs.org +// (c) 2009-2012 Jeremy Ashkenas, DocumentCloud Inc. +// Underscore may be freely distributed under the MIT license. + +(function() { + + // Baseline setup + // -------------- + + // Establish the root object, `window` in the browser, or `global` on the server. + var root = this; + + // Save the previous value of the `_` variable. + var previousUnderscore = root._; + + // Establish the object that gets returned to break out of a loop iteration. + var breaker = {}; + + // Save bytes in the minified (but not gzipped) version: + var ArrayProto = Array.prototype, ObjProto = Object.prototype, FuncProto = Function.prototype; + + // Create quick reference variables for speed access to core prototypes. + var push = ArrayProto.push, + slice = ArrayProto.slice, + concat = ArrayProto.concat, + unshift = ArrayProto.unshift, + toString = ObjProto.toString, + hasOwnProperty = ObjProto.hasOwnProperty; + + // All **ECMAScript 5** native function implementations that we hope to use + // are declared here. + var + nativeForEach = ArrayProto.forEach, + nativeMap = ArrayProto.map, + nativeReduce = ArrayProto.reduce, + nativeReduceRight = ArrayProto.reduceRight, + nativeFilter = ArrayProto.filter, + nativeEvery = ArrayProto.every, + nativeSome = ArrayProto.some, + nativeIndexOf = ArrayProto.indexOf, + nativeLastIndexOf = ArrayProto.lastIndexOf, + nativeIsArray = Array.isArray, + nativeKeys = Object.keys, + nativeBind = FuncProto.bind; + + // Create a safe reference to the Underscore object for use below. + var _ = function(obj) { + if (obj instanceof _) return obj; + if (!(this instanceof _)) return new _(obj); + this._wrapped = obj; + }; + + // Export the Underscore object for **Node.js**, with + // backwards-compatibility for the old `require()` API. If we're in + // the browser, add `_` as a global object via a string identifier, + // for Closure Compiler "advanced" mode. + if (typeof exports !== 'undefined') { + if (typeof module !== 'undefined' && module.exports) { + exports = module.exports = _; + } + exports._ = _; + } else { + root['_'] = _; + } + + // Current version. + _.VERSION = '1.4.2'; + + // Collection Functions + // -------------------- + + // The cornerstone, an `each` implementation, aka `forEach`. + // Handles objects with the built-in `forEach`, arrays, and raw objects. + // Delegates to **ECMAScript 5**'s native `forEach` if available. + var each = _.each = _.forEach = function(obj, iterator, context) { + if (obj == null) return; + if (nativeForEach && obj.forEach === nativeForEach) { + obj.forEach(iterator, context); + } else if (obj.length === +obj.length) { + for (var i = 0, l = obj.length; i < l; i++) { + if (iterator.call(context, obj[i], i, obj) === breaker) return; + } + } else { + for (var key in obj) { + if (_.has(obj, key)) { + if (iterator.call(context, obj[key], key, obj) === breaker) return; + } + } + } + }; + + // Return the results of applying the iterator to each element. + // Delegates to **ECMAScript 5**'s native `map` if available. + _.map = _.collect = function(obj, iterator, context) { + var results = []; + if (obj == null) return results; + if (nativeMap && obj.map === nativeMap) return obj.map(iterator, context); + each(obj, function(value, index, list) { + results[results.length] = iterator.call(context, value, index, list); + }); + return results; + }; + + // **Reduce** builds up a single result from a list of values, aka `inject`, + // or `foldl`. Delegates to **ECMAScript 5**'s native `reduce` if available. + _.reduce = _.foldl = _.inject = function(obj, iterator, memo, context) { + var initial = arguments.length > 2; + if (obj == null) obj = []; + if (nativeReduce && obj.reduce === nativeReduce) { + if (context) iterator = _.bind(iterator, context); + return initial ? obj.reduce(iterator, memo) : obj.reduce(iterator); + } + each(obj, function(value, index, list) { + if (!initial) { + memo = value; + initial = true; + } else { + memo = iterator.call(context, memo, value, index, list); + } + }); + if (!initial) throw new TypeError('Reduce of empty array with no initial value'); + return memo; + }; + + // The right-associative version of reduce, also known as `foldr`. + // Delegates to **ECMAScript 5**'s native `reduceRight` if available. + _.reduceRight = _.foldr = function(obj, iterator, memo, context) { + var initial = arguments.length > 2; + if (obj == null) obj = []; + if (nativeReduceRight && obj.reduceRight === nativeReduceRight) { + if (context) iterator = _.bind(iterator, context); + return arguments.length > 2 ? obj.reduceRight(iterator, memo) : obj.reduceRight(iterator); + } + var length = obj.length; + if (length !== +length) { + var keys = _.keys(obj); + length = keys.length; + } + each(obj, function(value, index, list) { + index = keys ? keys[--length] : --length; + if (!initial) { + memo = obj[index]; + initial = true; + } else { + memo = iterator.call(context, memo, obj[index], index, list); + } + }); + if (!initial) throw new TypeError('Reduce of empty array with no initial value'); + return memo; + }; + + // Return the first value which passes a truth test. Aliased as `detect`. + _.find = _.detect = function(obj, iterator, context) { + var result; + any(obj, function(value, index, list) { + if (iterator.call(context, value, index, list)) { + result = value; + return true; + } + }); + return result; + }; + + // Return all the elements that pass a truth test. + // Delegates to **ECMAScript 5**'s native `filter` if available. + // Aliased as `select`. + _.filter = _.select = function(obj, iterator, context) { + var results = []; + if (obj == null) return results; + if (nativeFilter && obj.filter === nativeFilter) return obj.filter(iterator, context); + each(obj, function(value, index, list) { + if (iterator.call(context, value, index, list)) results[results.length] = value; + }); + return results; + }; + + // Return all the elements for which a truth test fails. + _.reject = function(obj, iterator, context) { + var results = []; + if (obj == null) return results; + each(obj, function(value, index, list) { + if (!iterator.call(context, value, index, list)) results[results.length] = value; + }); + return results; + }; + + // Determine whether all of the elements match a truth test. + // Delegates to **ECMAScript 5**'s native `every` if available. + // Aliased as `all`. + _.every = _.all = function(obj, iterator, context) { + iterator || (iterator = _.identity); + var result = true; + if (obj == null) return result; + if (nativeEvery && obj.every === nativeEvery) return obj.every(iterator, context); + each(obj, function(value, index, list) { + if (!(result = result && iterator.call(context, value, index, list))) return breaker; + }); + return !!result; + }; + + // Determine if at least one element in the object matches a truth test. + // Delegates to **ECMAScript 5**'s native `some` if available. + // Aliased as `any`. + var any = _.some = _.any = function(obj, iterator, context) { + iterator || (iterator = _.identity); + var result = false; + if (obj == null) return result; + if (nativeSome && obj.some === nativeSome) return obj.some(iterator, context); + each(obj, function(value, index, list) { + if (result || (result = iterator.call(context, value, index, list))) return breaker; + }); + return !!result; + }; + + // Determine if the array or object contains a given value (using `===`). + // Aliased as `include`. + _.contains = _.include = function(obj, target) { + var found = false; + if (obj == null) return found; + if (nativeIndexOf && obj.indexOf === nativeIndexOf) return obj.indexOf(target) != -1; + found = any(obj, function(value) { + return value === target; + }); + return found; + }; + + // Invoke a method (with arguments) on every item in a collection. + _.invoke = function(obj, method) { + var args = slice.call(arguments, 2); + return _.map(obj, function(value) { + return (_.isFunction(method) ? method : value[method]).apply(value, args); + }); + }; + + // Convenience version of a common use case of `map`: fetching a property. + _.pluck = function(obj, key) { + return _.map(obj, function(value){ return value[key]; }); + }; + + // Convenience version of a common use case of `filter`: selecting only objects + // with specific `key:value` pairs. + _.where = function(obj, attrs) { + if (_.isEmpty(attrs)) return []; + return _.filter(obj, function(value) { + for (var key in attrs) { + if (attrs[key] !== value[key]) return false; + } + return true; + }); + }; + + // Return the maximum element or (element-based computation). + // Can't optimize arrays of integers longer than 65,535 elements. + // See: https://bugs.webkit.org/show_bug.cgi?id=80797 + _.max = function(obj, iterator, context) { + if (!iterator && _.isArray(obj) && obj[0] === +obj[0] && obj.length < 65535) { + return Math.max.apply(Math, obj); + } + if (!iterator && _.isEmpty(obj)) return -Infinity; + var result = {computed : -Infinity}; + each(obj, function(value, index, list) { + var computed = iterator ? iterator.call(context, value, index, list) : value; + computed >= result.computed && (result = {value : value, computed : computed}); + }); + return result.value; + }; + + // Return the minimum element (or element-based computation). + _.min = function(obj, iterator, context) { + if (!iterator && _.isArray(obj) && obj[0] === +obj[0] && obj.length < 65535) { + return Math.min.apply(Math, obj); + } + if (!iterator && _.isEmpty(obj)) return Infinity; + var result = {computed : Infinity}; + each(obj, function(value, index, list) { + var computed = iterator ? iterator.call(context, value, index, list) : value; + computed < result.computed && (result = {value : value, computed : computed}); + }); + return result.value; + }; + + // Shuffle an array. + _.shuffle = function(obj) { + var rand; + var index = 0; + var shuffled = []; + each(obj, function(value) { + rand = _.random(index++); + shuffled[index - 1] = shuffled[rand]; + shuffled[rand] = value; + }); + return shuffled; + }; + + // An internal function to generate lookup iterators. + var lookupIterator = function(value) { + return _.isFunction(value) ? value : function(obj){ return obj[value]; }; + }; + + // Sort the object's values by a criterion produced by an iterator. + _.sortBy = function(obj, value, context) { + var iterator = lookupIterator(value); + return _.pluck(_.map(obj, function(value, index, list) { + return { + value : value, + index : index, + criteria : iterator.call(context, value, index, list) + }; + }).sort(function(left, right) { + var a = left.criteria; + var b = right.criteria; + if (a !== b) { + if (a > b || a === void 0) return 1; + if (a < b || b === void 0) return -1; + } + return left.index < right.index ? -1 : 1; + }), 'value'); + }; + + // An internal function used for aggregate "group by" operations. + var group = function(obj, value, context, behavior) { + var result = {}; + var iterator = lookupIterator(value); + each(obj, function(value, index) { + var key = iterator.call(context, value, index, obj); + behavior(result, key, value); + }); + return result; + }; + + // Groups the object's values by a criterion. Pass either a string attribute + // to group by, or a function that returns the criterion. + _.groupBy = function(obj, value, context) { + return group(obj, value, context, function(result, key, value) { + (_.has(result, key) ? result[key] : (result[key] = [])).push(value); + }); + }; + + // Counts instances of an object that group by a certain criterion. Pass + // either a string attribute to count by, or a function that returns the + // criterion. + _.countBy = function(obj, value, context) { + return group(obj, value, context, function(result, key, value) { + if (!_.has(result, key)) result[key] = 0; + result[key]++; + }); + }; + + // Use a comparator function to figure out the smallest index at which + // an object should be inserted so as to maintain order. Uses binary search. + _.sortedIndex = function(array, obj, iterator, context) { + iterator = iterator == null ? _.identity : lookupIterator(iterator); + var value = iterator.call(context, obj); + var low = 0, high = array.length; + while (low < high) { + var mid = (low + high) >>> 1; + iterator.call(context, array[mid]) < value ? low = mid + 1 : high = mid; + } + return low; + }; + + // Safely convert anything iterable into a real, live array. + _.toArray = function(obj) { + if (!obj) return []; + if (obj.length === +obj.length) return slice.call(obj); + return _.values(obj); + }; + + // Return the number of elements in an object. + _.size = function(obj) { + return (obj.length === +obj.length) ? obj.length : _.keys(obj).length; + }; + + // Array Functions + // --------------- + + // Get the first element of an array. Passing **n** will return the first N + // values in the array. Aliased as `head` and `take`. The **guard** check + // allows it to work with `_.map`. + _.first = _.head = _.take = function(array, n, guard) { + return (n != null) && !guard ? slice.call(array, 0, n) : array[0]; + }; + + // Returns everything but the last entry of the array. Especially useful on + // the arguments object. Passing **n** will return all the values in + // the array, excluding the last N. The **guard** check allows it to work with + // `_.map`. + _.initial = function(array, n, guard) { + return slice.call(array, 0, array.length - ((n == null) || guard ? 1 : n)); + }; + + // Get the last element of an array. Passing **n** will return the last N + // values in the array. The **guard** check allows it to work with `_.map`. + _.last = function(array, n, guard) { + if ((n != null) && !guard) { + return slice.call(array, Math.max(array.length - n, 0)); + } else { + return array[array.length - 1]; + } + }; + + // Returns everything but the first entry of the array. Aliased as `tail` and `drop`. + // Especially useful on the arguments object. Passing an **n** will return + // the rest N values in the array. The **guard** + // check allows it to work with `_.map`. + _.rest = _.tail = _.drop = function(array, n, guard) { + return slice.call(array, (n == null) || guard ? 1 : n); + }; + + // Trim out all falsy values from an array. + _.compact = function(array) { + return _.filter(array, function(value){ return !!value; }); + }; + + // Internal implementation of a recursive `flatten` function. + var flatten = function(input, shallow, output) { + each(input, function(value) { + if (_.isArray(value)) { + shallow ? push.apply(output, value) : flatten(value, shallow, output); + } else { + output.push(value); + } + }); + return output; + }; + + // Return a completely flattened version of an array. + _.flatten = function(array, shallow) { + return flatten(array, shallow, []); + }; + + // Return a version of the array that does not contain the specified value(s). + _.without = function(array) { + return _.difference(array, slice.call(arguments, 1)); + }; + + // Produce a duplicate-free version of the array. If the array has already + // been sorted, you have the option of using a faster algorithm. + // Aliased as `unique`. + _.uniq = _.unique = function(array, isSorted, iterator, context) { + var initial = iterator ? _.map(array, iterator, context) : array; + var results = []; + var seen = []; + each(initial, function(value, index) { + if (isSorted ? (!index || seen[seen.length - 1] !== value) : !_.contains(seen, value)) { + seen.push(value); + results.push(array[index]); + } + }); + return results; + }; + + // Produce an array that contains the union: each distinct element from all of + // the passed-in arrays. + _.union = function() { + return _.uniq(concat.apply(ArrayProto, arguments)); + }; + + // Produce an array that contains every item shared between all the + // passed-in arrays. + _.intersection = function(array) { + var rest = slice.call(arguments, 1); + return _.filter(_.uniq(array), function(item) { + return _.every(rest, function(other) { + return _.indexOf(other, item) >= 0; + }); + }); + }; + + // Take the difference between one array and a number of other arrays. + // Only the elements present in just the first array will remain. + _.difference = function(array) { + var rest = concat.apply(ArrayProto, slice.call(arguments, 1)); + return _.filter(array, function(value){ return !_.contains(rest, value); }); + }; + + // Zip together multiple lists into a single array -- elements that share + // an index go together. + _.zip = function() { + var args = slice.call(arguments); + var length = _.max(_.pluck(args, 'length')); + var results = new Array(length); + for (var i = 0; i < length; i++) { + results[i] = _.pluck(args, "" + i); + } + return results; + }; + + // Converts lists into objects. Pass either a single array of `[key, value]` + // pairs, or two parallel arrays of the same length -- one of keys, and one of + // the corresponding values. + _.object = function(list, values) { + var result = {}; + for (var i = 0, l = list.length; i < l; i++) { + if (values) { + result[list[i]] = values[i]; + } else { + result[list[i][0]] = list[i][1]; + } + } + return result; + }; + + // If the browser doesn't supply us with indexOf (I'm looking at you, **MSIE**), + // we need this function. Return the position of the first occurrence of an + // item in an array, or -1 if the item is not included in the array. + // Delegates to **ECMAScript 5**'s native `indexOf` if available. + // If the array is large and already in sort order, pass `true` + // for **isSorted** to use binary search. + _.indexOf = function(array, item, isSorted) { + if (array == null) return -1; + var i = 0, l = array.length; + if (isSorted) { + if (typeof isSorted == 'number') { + i = (isSorted < 0 ? Math.max(0, l + isSorted) : isSorted); + } else { + i = _.sortedIndex(array, item); + return array[i] === item ? i : -1; + } + } + if (nativeIndexOf && array.indexOf === nativeIndexOf) return array.indexOf(item, isSorted); + for (; i < l; i++) if (array[i] === item) return i; + return -1; + }; + + // Delegates to **ECMAScript 5**'s native `lastIndexOf` if available. + _.lastIndexOf = function(array, item, from) { + if (array == null) return -1; + var hasIndex = from != null; + if (nativeLastIndexOf && array.lastIndexOf === nativeLastIndexOf) { + return hasIndex ? array.lastIndexOf(item, from) : array.lastIndexOf(item); + } + var i = (hasIndex ? from : array.length); + while (i--) if (array[i] === item) return i; + return -1; + }; + + // Generate an integer Array containing an arithmetic progression. A port of + // the native Python `range()` function. See + // [the Python documentation](http://docs.python.org/library/functions.html#range). + _.range = function(start, stop, step) { + if (arguments.length <= 1) { + stop = start || 0; + start = 0; + } + step = arguments[2] || 1; + + var len = Math.max(Math.ceil((stop - start) / step), 0); + var idx = 0; + var range = new Array(len); + + while(idx < len) { + range[idx++] = start; + start += step; + } + + return range; + }; + + // Function (ahem) Functions + // ------------------ + + // Reusable constructor function for prototype setting. + var ctor = function(){}; + + // Create a function bound to a given object (assigning `this`, and arguments, + // optionally). Binding with arguments is also known as `curry`. + // Delegates to **ECMAScript 5**'s native `Function.bind` if available. + // We check for `func.bind` first, to fail fast when `func` is undefined. + _.bind = function bind(func, context) { + var bound, args; + if (func.bind === nativeBind && nativeBind) return nativeBind.apply(func, slice.call(arguments, 1)); + if (!_.isFunction(func)) throw new TypeError; + args = slice.call(arguments, 2); + return bound = function() { + if (!(this instanceof bound)) return func.apply(context, args.concat(slice.call(arguments))); + ctor.prototype = func.prototype; + var self = new ctor; + var result = func.apply(self, args.concat(slice.call(arguments))); + if (Object(result) === result) return result; + return self; + }; + }; + + // Bind all of an object's methods to that object. Useful for ensuring that + // all callbacks defined on an object belong to it. + _.bindAll = function(obj) { + var funcs = slice.call(arguments, 1); + if (funcs.length == 0) funcs = _.functions(obj); + each(funcs, function(f) { obj[f] = _.bind(obj[f], obj); }); + return obj; + }; + + // Memoize an expensive function by storing its results. + _.memoize = function(func, hasher) { + var memo = {}; + hasher || (hasher = _.identity); + return function() { + var key = hasher.apply(this, arguments); + return _.has(memo, key) ? memo[key] : (memo[key] = func.apply(this, arguments)); + }; + }; + + // Delays a function for the given number of milliseconds, and then calls + // it with the arguments supplied. + _.delay = function(func, wait) { + var args = slice.call(arguments, 2); + return setTimeout(function(){ return func.apply(null, args); }, wait); + }; + + // Defers a function, scheduling it to run after the current call stack has + // cleared. + _.defer = function(func) { + return _.delay.apply(_, [func, 1].concat(slice.call(arguments, 1))); + }; + + // Returns a function, that, when invoked, will only be triggered at most once + // during a given window of time. + _.throttle = function(func, wait) { + var context, args, timeout, throttling, more, result; + var whenDone = _.debounce(function(){ more = throttling = false; }, wait); + return function() { + context = this; args = arguments; + var later = function() { + timeout = null; + if (more) { + result = func.apply(context, args); + } + whenDone(); + }; + if (!timeout) timeout = setTimeout(later, wait); + if (throttling) { + more = true; + } else { + throttling = true; + result = func.apply(context, args); + } + whenDone(); + return result; + }; + }; + + // Returns a function, that, as long as it continues to be invoked, will not + // be triggered. The function will be called after it stops being called for + // N milliseconds. If `immediate` is passed, trigger the function on the + // leading edge, instead of the trailing. + _.debounce = function(func, wait, immediate) { + var timeout, result; + return function() { + var context = this, args = arguments; + var later = function() { + timeout = null; + if (!immediate) result = func.apply(context, args); + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) result = func.apply(context, args); + return result; + }; + }; + + // Returns a function that will be executed at most one time, no matter how + // often you call it. Useful for lazy initialization. + _.once = function(func) { + var ran = false, memo; + return function() { + if (ran) return memo; + ran = true; + memo = func.apply(this, arguments); + func = null; + return memo; + }; + }; + + // Returns the first function passed as an argument to the second, + // allowing you to adjust arguments, run code before and after, and + // conditionally execute the original function. + _.wrap = function(func, wrapper) { + return function() { + var args = [func]; + push.apply(args, arguments); + return wrapper.apply(this, args); + }; + }; + + // Returns a function that is the composition of a list of functions, each + // consuming the return value of the function that follows. + _.compose = function() { + var funcs = arguments; + return function() { + var args = arguments; + for (var i = funcs.length - 1; i >= 0; i--) { + args = [funcs[i].apply(this, args)]; + } + return args[0]; + }; + }; + + // Returns a function that will only be executed after being called N times. + _.after = function(times, func) { + if (times <= 0) return func(); + return function() { + if (--times < 1) { + return func.apply(this, arguments); + } + }; + }; + + // Object Functions + // ---------------- + + // Retrieve the names of an object's properties. + // Delegates to **ECMAScript 5**'s native `Object.keys` + _.keys = nativeKeys || function(obj) { + if (obj !== Object(obj)) throw new TypeError('Invalid object'); + var keys = []; + for (var key in obj) if (_.has(obj, key)) keys[keys.length] = key; + return keys; + }; + + // Retrieve the values of an object's properties. + _.values = function(obj) { + var values = []; + for (var key in obj) if (_.has(obj, key)) values.push(obj[key]); + return values; + }; + + // Convert an object into a list of `[key, value]` pairs. + _.pairs = function(obj) { + var pairs = []; + for (var key in obj) if (_.has(obj, key)) pairs.push([key, obj[key]]); + return pairs; + }; + + // Invert the keys and values of an object. The values must be serializable. + _.invert = function(obj) { + var result = {}; + for (var key in obj) if (_.has(obj, key)) result[obj[key]] = key; + return result; + }; + + // Return a sorted list of the function names available on the object. + // Aliased as `methods` + _.functions = _.methods = function(obj) { + var names = []; + for (var key in obj) { + if (_.isFunction(obj[key])) names.push(key); + } + return names.sort(); + }; + + // Extend a given object with all the properties in passed-in object(s). + _.extend = function(obj) { + each(slice.call(arguments, 1), function(source) { + for (var prop in source) { + obj[prop] = source[prop]; + } + }); + return obj; + }; + + // Return a copy of the object only containing the whitelisted properties. + _.pick = function(obj) { + var copy = {}; + var keys = concat.apply(ArrayProto, slice.call(arguments, 1)); + each(keys, function(key) { + if (key in obj) copy[key] = obj[key]; + }); + return copy; + }; + + // Return a copy of the object without the blacklisted properties. + _.omit = function(obj) { + var copy = {}; + var keys = concat.apply(ArrayProto, slice.call(arguments, 1)); + for (var key in obj) { + if (!_.contains(keys, key)) copy[key] = obj[key]; + } + return copy; + }; + + // Fill in a given object with default properties. + _.defaults = function(obj) { + each(slice.call(arguments, 1), function(source) { + for (var prop in source) { + if (obj[prop] == null) obj[prop] = source[prop]; + } + }); + return obj; + }; + + // Create a (shallow-cloned) duplicate of an object. + _.clone = function(obj) { + if (!_.isObject(obj)) return obj; + return _.isArray(obj) ? obj.slice() : _.extend({}, obj); + }; + + // Invokes interceptor with the obj, and then returns obj. + // The primary purpose of this method is to "tap into" a method chain, in + // order to perform operations on intermediate results within the chain. + _.tap = function(obj, interceptor) { + interceptor(obj); + return obj; + }; + + // Internal recursive comparison function for `isEqual`. + var eq = function(a, b, aStack, bStack) { + // Identical objects are equal. `0 === -0`, but they aren't identical. + // See the Harmony `egal` proposal: http://wiki.ecmascript.org/doku.php?id=harmony:egal. + if (a === b) return a !== 0 || 1 / a == 1 / b; + // A strict comparison is necessary because `null == undefined`. + if (a == null || b == null) return a === b; + // Unwrap any wrapped objects. + if (a instanceof _) a = a._wrapped; + if (b instanceof _) b = b._wrapped; + // Compare `[[Class]]` names. + var className = toString.call(a); + if (className != toString.call(b)) return false; + switch (className) { + // Strings, numbers, dates, and booleans are compared by value. + case '[object String]': + // Primitives and their corresponding object wrappers are equivalent; thus, `"5"` is + // equivalent to `new String("5")`. + return a == String(b); + case '[object Number]': + // `NaN`s are equivalent, but non-reflexive. An `egal` comparison is performed for + // other numeric values. + return a != +a ? b != +b : (a == 0 ? 1 / a == 1 / b : a == +b); + case '[object Date]': + case '[object Boolean]': + // Coerce dates and booleans to numeric primitive values. Dates are compared by their + // millisecond representations. Note that invalid dates with millisecond representations + // of `NaN` are not equivalent. + return +a == +b; + // RegExps are compared by their source patterns and flags. + case '[object RegExp]': + return a.source == b.source && + a.global == b.global && + a.multiline == b.multiline && + a.ignoreCase == b.ignoreCase; + } + if (typeof a != 'object' || typeof b != 'object') return false; + // Assume equality for cyclic structures. The algorithm for detecting cyclic + // structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`. + var length = aStack.length; + while (length--) { + // Linear search. Performance is inversely proportional to the number of + // unique nested structures. + if (aStack[length] == a) return bStack[length] == b; + } + // Add the first object to the stack of traversed objects. + aStack.push(a); + bStack.push(b); + var size = 0, result = true; + // Recursively compare objects and arrays. + if (className == '[object Array]') { + // Compare array lengths to determine if a deep comparison is necessary. + size = a.length; + result = size == b.length; + if (result) { + // Deep compare the contents, ignoring non-numeric properties. + while (size--) { + if (!(result = eq(a[size], b[size], aStack, bStack))) break; + } + } + } else { + // Objects with different constructors are not equivalent, but `Object`s + // from different frames are. + var aCtor = a.constructor, bCtor = b.constructor; + if (aCtor !== bCtor && !(_.isFunction(aCtor) && (aCtor instanceof aCtor) && + _.isFunction(bCtor) && (bCtor instanceof bCtor))) { + return false; + } + // Deep compare objects. + for (var key in a) { + if (_.has(a, key)) { + // Count the expected number of properties. + size++; + // Deep compare each member. + if (!(result = _.has(b, key) && eq(a[key], b[key], aStack, bStack))) break; + } + } + // Ensure that both objects contain the same number of properties. + if (result) { + for (key in b) { + if (_.has(b, key) && !(size--)) break; + } + result = !size; + } + } + // Remove the first object from the stack of traversed objects. + aStack.pop(); + bStack.pop(); + return result; + }; + + // Perform a deep comparison to check if two objects are equal. + _.isEqual = function(a, b) { + return eq(a, b, [], []); + }; + + // Is a given array, string, or object empty? + // An "empty" object has no enumerable own-properties. + _.isEmpty = function(obj) { + if (obj == null) return true; + if (_.isArray(obj) || _.isString(obj)) return obj.length === 0; + for (var key in obj) if (_.has(obj, key)) return false; + return true; + }; + + // Is a given value a DOM element? + _.isElement = function(obj) { + return !!(obj && obj.nodeType === 1); + }; + + // Is a given value an array? + // Delegates to ECMA5's native Array.isArray + _.isArray = nativeIsArray || function(obj) { + return toString.call(obj) == '[object Array]'; + }; + + // Is a given variable an object? + _.isObject = function(obj) { + return obj === Object(obj); + }; + + // Add some isType methods: isArguments, isFunction, isString, isNumber, isDate, isRegExp. + each(['Arguments', 'Function', 'String', 'Number', 'Date', 'RegExp'], function(name) { + _['is' + name] = function(obj) { + return toString.call(obj) == '[object ' + name + ']'; + }; + }); + + // Define a fallback version of the method in browsers (ahem, IE), where + // there isn't any inspectable "Arguments" type. + if (!_.isArguments(arguments)) { + _.isArguments = function(obj) { + return !!(obj && _.has(obj, 'callee')); + }; + } + + // Optimize `isFunction` if appropriate. + if (typeof (/./) !== 'function') { + _.isFunction = function(obj) { + return typeof obj === 'function'; + }; + } + + // Is a given object a finite number? + _.isFinite = function(obj) { + return _.isNumber(obj) && isFinite(obj); + }; + + // Is the given value `NaN`? (NaN is the only number which does not equal itself). + _.isNaN = function(obj) { + return _.isNumber(obj) && obj != +obj; + }; + + // Is a given value a boolean? + _.isBoolean = function(obj) { + return obj === true || obj === false || toString.call(obj) == '[object Boolean]'; + }; + + // Is a given value equal to null? + _.isNull = function(obj) { + return obj === null; + }; + + // Is a given variable undefined? + _.isUndefined = function(obj) { + return obj === void 0; + }; + + // Shortcut function for checking if an object has a given property directly + // on itself (in other words, not on a prototype). + _.has = function(obj, key) { + return hasOwnProperty.call(obj, key); + }; + + // Utility Functions + // ----------------- + + // Run Underscore.js in *noConflict* mode, returning the `_` variable to its + // previous owner. Returns a reference to the Underscore object. + _.noConflict = function() { + root._ = previousUnderscore; + return this; + }; + + // Keep the identity function around for default iterators. + _.identity = function(value) { + return value; + }; + + // Run a function **n** times. + _.times = function(n, iterator, context) { + for (var i = 0; i < n; i++) iterator.call(context, i); + }; + + // Return a random integer between min and max (inclusive). + _.random = function(min, max) { + if (max == null) { + max = min; + min = 0; + } + return min + (0 | Math.random() * (max - min + 1)); + }; + + // List of HTML entities for escaping. + var entityMap = { + escape: { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/' + } + }; + entityMap.unescape = _.invert(entityMap.escape); + + // Regexes containing the keys and values listed immediately above. + var entityRegexes = { + escape: new RegExp('[' + _.keys(entityMap.escape).join('') + ']', 'g'), + unescape: new RegExp('(' + _.keys(entityMap.unescape).join('|') + ')', 'g') + }; + + // Functions for escaping and unescaping strings to/from HTML interpolation. + _.each(['escape', 'unescape'], function(method) { + _[method] = function(string) { + if (string == null) return ''; + return ('' + string).replace(entityRegexes[method], function(match) { + return entityMap[method][match]; + }); + }; + }); + + // If the value of the named property is a function then invoke it; + // otherwise, return it. + _.result = function(object, property) { + if (object == null) return null; + var value = object[property]; + return _.isFunction(value) ? value.call(object) : value; + }; + + // Add your own custom functions to the Underscore object. + _.mixin = function(obj) { + each(_.functions(obj), function(name){ + var func = _[name] = obj[name]; + _.prototype[name] = function() { + var args = [this._wrapped]; + push.apply(args, arguments); + return result.call(this, func.apply(_, args)); + }; + }); + }; + + // Generate a unique integer id (unique within the entire client session). + // Useful for temporary DOM ids. + var idCounter = 0; + _.uniqueId = function(prefix) { + var id = idCounter++; + return prefix ? prefix + id : id; + }; + + // By default, Underscore uses ERB-style template delimiters, change the + // following template settings to use alternative delimiters. + _.templateSettings = { + evaluate : /<%([\s\S]+?)%>/g, + interpolate : /<%=([\s\S]+?)%>/g, + escape : /<%-([\s\S]+?)%>/g + }; + + // When customizing `templateSettings`, if you don't want to define an + // interpolation, evaluation or escaping regex, we need one that is + // guaranteed not to match. + var noMatch = /(.)^/; + + // Certain characters need to be escaped so that they can be put into a + // string literal. + var escapes = { + "'": "'", + '\\': '\\', + '\r': 'r', + '\n': 'n', + '\t': 't', + '\u2028': 'u2028', + '\u2029': 'u2029' + }; + + var escaper = /\\|'|\r|\n|\t|\u2028|\u2029/g; + + // JavaScript micro-templating, similar to John Resig's implementation. + // Underscore templating handles arbitrary delimiters, preserves whitespace, + // and correctly escapes quotes within interpolated code. + _.template = function(text, data, settings) { + settings = _.defaults({}, settings, _.templateSettings); + + // Combine delimiters into one regular expression via alternation. + var matcher = new RegExp([ + (settings.escape || noMatch).source, + (settings.interpolate || noMatch).source, + (settings.evaluate || noMatch).source + ].join('|') + '|$', 'g'); + + // Compile the template source, escaping string literals appropriately. + var index = 0; + var source = "__p+='"; + text.replace(matcher, function(match, escape, interpolate, evaluate, offset) { + source += text.slice(index, offset) + .replace(escaper, function(match) { return '\\' + escapes[match]; }); + source += + escape ? "'+\n((__t=(" + escape + "))==null?'':_.escape(__t))+\n'" : + interpolate ? "'+\n((__t=(" + interpolate + "))==null?'':__t)+\n'" : + evaluate ? "';\n" + evaluate + "\n__p+='" : ''; + index = offset + match.length; + }); + source += "';\n"; + + // If a variable is not specified, place data values in local scope. + if (!settings.variable) source = 'with(obj||{}){\n' + source + '}\n'; + + source = "var __t,__p='',__j=Array.prototype.join," + + "print=function(){__p+=__j.call(arguments,'');};\n" + + source + "return __p;\n"; + + try { + var render = new Function(settings.variable || 'obj', '_', source); + } catch (e) { + e.source = source; + throw e; + } + + if (data) return render(data, _); + var template = function(data) { + return render.call(this, data, _); + }; + + // Provide the compiled function source as a convenience for precompilation. + template.source = 'function(' + (settings.variable || 'obj') + '){\n' + source + '}'; + + return template; + }; + + // Add a "chain" function, which will delegate to the wrapper. + _.chain = function(obj) { + return _(obj).chain(); + }; + + // OOP + // --------------- + // If Underscore is called as a function, it returns a wrapped object that + // can be used OO-style. This wrapper holds altered versions of all the + // underscore functions. Wrapped objects may be chained. + + // Helper function to continue chaining intermediate results. + var result = function(obj) { + return this._chain ? _(obj).chain() : obj; + }; + + // Add all of the Underscore functions to the wrapper object. + _.mixin(_); + + // Add all mutator Array functions to the wrapper. + each(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(name) { + var method = ArrayProto[name]; + _.prototype[name] = function() { + var obj = this._wrapped; + method.apply(obj, arguments); + if ((name == 'shift' || name == 'splice') && obj.length === 0) delete obj[0]; + return result.call(this, obj); + }; + }); + + // Add all accessor Array functions to the wrapper. + each(['concat', 'join', 'slice'], function(name) { + var method = ArrayProto[name]; + _.prototype[name] = function() { + return result.call(this, method.apply(this._wrapped, arguments)); + }; + }); + + _.extend(_.prototype, { + + // Start chaining a wrapped Underscore object. + chain: function() { + this._chain = true; + return this; + }, + + // Extracts the result from a wrapped and chained object. + value: function() { + return this._wrapped; + } + + }); + +}).call(this); diff --git a/tests/frontend/runner.css b/tests/frontend/runner.css new file mode 100644 index 00000000..0e4b5fd1 --- /dev/null +++ b/tests/frontend/runner.css @@ -0,0 +1,228 @@ +html { + height: 100%; +} + +body { + padding: 0px; + margin: 0px; + height: 100%; +} + +#console { + display: none; +} + +#iframe-container { + width: 50%; + height: 100%; + float:right; +} + +#iframe-container iframe { + width: 100%; + height: 100%; +} + +#mocha { + font: 20px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif; + border-right: 2px solid #999; + width: 50%; + height: 100%; + position: absolute; + overflow: auto; + float:left; +} + +#mocha #report { + margin-top: 50px; +} + +#mocha ul, #mocha li { + margin: 0; + padding: 0; +} + +#mocha ul { + list-style: none; +} + +#mocha h1, #mocha h2 { + margin: 0; +} + +#mocha h1 { + margin-top: 15px; + font-size: 1em; + font-weight: 200; +} + +#mocha h1 a:visited +{ + color: #00E; +} + +#mocha .suite .suite h1 { + margin-top: 0; + font-size: .8em; +} + +#mocha h2 { + font-size: 12px; + font-weight: normal; + cursor: pointer; +} + +#mocha .suite { + margin-left: 15px; +} + +#mocha .test { + margin-left: 15px; +} + +#mocha .test:hover h2::after { + position: relative; + top: 0; + right: -10px; + content: '(view source)'; + font-size: 12px; + font-family: arial; + color: #888; +} + +#mocha .test.pending:hover h2::after { + content: '(pending)'; + font-family: arial; +} + +#mocha .test.pass.medium .duration { + background: #C09853; +} + +#mocha .test.pass.slow .duration { + background: #B94A48; +} + +#mocha .test.pass::before { + content: '✓'; + font-size: 12px; + display: block; + float: left; + margin-right: 5px; + color: #00d6b2; +} + +#mocha .test.pass .duration { + font-size: 9px; + margin-left: 5px; + padding: 2px 5px; + color: white; + -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.2); + -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.2); + box-shadow: inset 0 1px 1px rgba(0,0,0,.2); + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + -ms-border-radius: 5px; + -o-border-radius: 5px; + border-radius: 5px; +} + +#mocha .test.pass.fast .duration { + display: none; +} + +#mocha .test.pending { + color: #0b97c4; +} + +#mocha .test.pending::before { + content: '◦'; + color: #0b97c4; +} + +#mocha .test.fail { + color: #c00; +} + +#mocha .test.fail pre { + color: black; +} + +#mocha .test.fail::before { + content: '✖'; + font-size: 12px; + display: block; + float: left; + margin-right: 5px; + color: #c00; +} + +#mocha .test pre.error { + color: #c00; +} + +#mocha .test pre { + display: inline-block; + font: 12px/1.5 monaco, monospace; + margin: 5px; + padding: 15px; + border: 1px solid #eee; + border-bottom-color: #ddd; + -webkit-border-radius: 3px; + -webkit-box-shadow: 0 1px 3px #eee; +} + +#report.pass .test.fail { + display: none; +} + +#report.fail .test.pass { + display: none; +} + +#error { + color: #c00; + font-size: 1.5 em; + font-weight: 100; + letter-spacing: 1px; +} + +#stats { + position: fixed; + top: 15px; + right: 52%; + font-size: 12px; + margin: 0; + color: #888; +} + +#stats .progress { + float: right; + padding-top: 0; +} + +#stats em { + color: black; +} + +#stats a { + text-decoration: none; + color: inherit; +} + +#stats a:hover { + border-bottom: 1px solid #eee; +} + +#stats li { + display: inline-block; + margin: 0 5px; + list-style: none; + padding-top: 11px; +} + +code .comment { color: #ddd } +code .init { color: #2F6FAD } +code .string { color: #5890AD } +code .keyword { color: #8A6343 } +code .number { color: #2F6FAD } diff --git a/tests/frontend/runner.js b/tests/frontend/runner.js new file mode 100644 index 00000000..1679664b --- /dev/null +++ b/tests/frontend/runner.js @@ -0,0 +1,199 @@ +$(function(){ + function Base(runner) { + var self = this + , stats = this.stats = { suites: 0, tests: 0, passes: 0, pending: 0, failures: 0 } + , failures = this.failures = []; + + if (!runner) return; + this.runner = runner; + + runner.on('start', function(){ + stats.start = new Date; + }); + + runner.on('suite', function(suite){ + stats.suites = stats.suites || 0; + suite.root || stats.suites++; + }); + + runner.on('test end', function(test){ + stats.tests = stats.tests || 0; + stats.tests++; + }); + + runner.on('pass', function(test){ + stats.passes = stats.passes || 0; + + var medium = test.slow() / 2; + test.speed = test.duration > test.slow() + ? 'slow' + : test.duration > medium + ? 'medium' + : 'fast'; + + stats.passes++; + }); + + runner.on('fail', function(test, err){ + stats.failures = stats.failures || 0; + stats.failures++; + test.err = err; + failures.push(test); + }); + + runner.on('end', function(){ + stats.end = new Date; + stats.duration = new Date - stats.start; + }); + + runner.on('pending', function(){ + stats.pending++; + }); + } + + /* + This reporter wraps the original html reporter plus reports plain text into a hidden div. + This allows the webdriver client to pick up the test results + */ + var WebdriverAndHtmlReporter = function(html_reporter){ + return function(runner){ + Base.call(this, runner); + + // Scroll down test display after each test + mocha = $('#mocha')[0]; + runner.on('test', function(){ + mocha.scrollTop = mocha.scrollHeight; + }); + + //initalize the html reporter first + html_reporter(runner); + + var $console = $("#console"); + var level = 0; + var append = function(){ + var text = Array.prototype.join.apply(arguments, [" "]); + var oldText = $console.text(); + + var space = ""; + for(var i=0;i<level*2;i++){ + space+=" "; + } + + var splitedText = ""; + _(text.split("\n")).each(function(line){ + while(line.length > 0){ + var split = line.substr(0,100); + line = line.substr(100); + if(splitedText.length > 0) splitedText+="\n"; + splitedText += split; + } + }); + + //indent all lines with the given amount of space + var newText = _(splitedText.split("\n")).map(function(line){ + return space + line; + }).join("\\n"); + + $console.text(oldText + newText + "\\n"); + } + + runner.on('suite', function(suite){ + if (suite.root) return; + + append(suite.title); + level++; + }); + + runner.on('suite end', function(suite){ + if (suite.root) return; + level--; + + if(level == 0) { + append(""); + } + }); + + var stringifyException = function(exception){ + var err = exception.stack || exception.toString(); + + // FF / Opera do not add the message + if (!~err.indexOf(exception.message)) { + err = exception.message + '\n' + err; + } + + // <=IE7 stringifies to [Object Error]. Since it can be overloaded, we + // check for the result of the stringifying. + if ('[object Error]' == err) err = exception.message; + + // Safari doesn't give you a stack. Let's at least provide a source line. + if (!exception.stack && exception.sourceURL && exception.line !== undefined) { + err += "\n(" + exception.sourceURL + ":" + exception.line + ")"; + } + + return err; + } + + var killTimeout; + runner.on('test end', function(test){ + if ('passed' == test.state) { + append("->","[green]PASSED[clear] :", test.title); + } else if (test.pending) { + append("->","[yellow]PENDING[clear]:", test.title); + } else { + append("->","[red]FAILED[clear] :", test.title, stringifyException(test.err)); + } + + if(killTimeout) clearTimeout(killTimeout); + killTimeout = setTimeout(function(){ + append("FINISHED - [red]no test started since 3 minutes, tests stopped[clear]"); + }, 60000 * 3); + }); + + var total = runner.total; + runner.on('end', function(){ + if(stats.tests >= total){ + var minutes = Math.floor(stats.duration / 1000 / 60); + var seconds = Math.round((stats.duration / 1000) % 60); + + append("FINISHED -", stats.passes, "tests passed,", stats.failures, "tests failed, duration: " + minutes + ":" + seconds); + } + }); + } + } + + //allow cross iframe access + if ((!$.browser.msie) && (!($.browser.mozilla && $.browser.version.indexOf("1.8.") == 0))) { + document.domain = document.domain; // for comet + } + + //http://stackoverflow.com/questions/1403888/get-url-parameter-with-jquery + var getURLParameter = function (name) { + return decodeURI( + (RegExp(name + '=' + '(.+?)(&|$)').exec(location.search)||[,null])[1] + ); + } + + //get the list of specs and filter it if requested + var specs = specs_list.slice(); + + //inject spec scripts into the dom + var $body = $('body'); + $.each(specs, function(i, spec){ + $body.append('<script src="specs/' + spec + '"></script>') + }); + + //initalize the test helper + helper.init(function(){ + //configure and start the test framework + var grep = getURLParameter("grep"); + if(grep != "null"){ + mocha.grep(grep); + } + + mocha.ignoreLeaks(); + + mocha.reporter(WebdriverAndHtmlReporter(mocha._reporter)); + + mocha.run(); + }); +}); \ No newline at end of file diff --git a/tests/frontend/specs/button_bold.js b/tests/frontend/specs/button_bold.js new file mode 100644 index 00000000..1feafe61 --- /dev/null +++ b/tests/frontend/specs/button_bold.js @@ -0,0 +1,36 @@ +describe("bold button", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + it("makes text bold", function(done) { + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + //get the first text element out of the inner iframe + var $firstTextElement = inner$("div").first(); + + //select this text element + $firstTextElement.sendkeys('{selectall}'); + + //get the bold button and click it + var $boldButton = chrome$(".buttonicon-bold"); + $boldButton.click(); + + //ace creates a new dom element when you press a button, so just get the first text element again + var $newFirstTextElement = inner$("div").first(); + + // is there a <b> element now? + var isBold = $newFirstTextElement.find("b").length === 1; + + //expect it to be bold + expect(isBold).to.be(true); + + //make sure the text hasn't changed + expect($newFirstTextElement.text()).to.eql($firstTextElement.text()); + + done(); + }); +}); \ No newline at end of file diff --git a/tests/frontend/specs/button_clear_authorship_colors.js b/tests/frontend/specs/button_clear_authorship_colors.js new file mode 100644 index 00000000..5db35612 --- /dev/null +++ b/tests/frontend/specs/button_clear_authorship_colors.js @@ -0,0 +1,54 @@ +describe("clear authorship colors button", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + it("makes text clear authorship colors", function(done) { + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + // override the confirm dialogue functioon + helper.padChrome$.window.confirm = function(){ + return true; + } + + //get the first text element out of the inner iframe + var $firstTextElement = inner$("div").first(); + + // Get the original text + var originalText = inner$("div").first().text(); + + // Set some new text + var sentText = "Hello"; + + //select this text element + $firstTextElement.sendkeys('{selectall}'); + $firstTextElement.sendkeys(sentText); + $firstTextElement.sendkeys('{rightarrow}'); + + helper.waitFor(function(){ + return inner$("div span").first().attr("class").indexOf("author") !== -1; // wait until we have the full value available + }).done(function(){ + //IE hates you if you don't give focus to the inner frame bevore you do a clearAuthorship + inner$("div").first().focus(); + + //get the clear authorship colors button and click it + var $clearauthorshipcolorsButton = chrome$(".buttonicon-clearauthorship"); + $clearauthorshipcolorsButton.click(); + + // does the first divs span include an author class? + console.log(inner$("div span").first().attr("class")); + var hasAuthorClass = inner$("div span").first().attr("class").indexOf("author") !== -1; + //expect(hasAuthorClass).to.be(false); + + // does the first div include an author class? + var hasAuthorClass = inner$("div").first().attr("class").indexOf("author") !== -1; + expect(hasAuthorClass).to.be(false); + + done(); + }); + + }); +}); diff --git a/tests/frontend/specs/button_indentation.js b/tests/frontend/specs/button_indentation.js new file mode 100644 index 00000000..9c8e317e --- /dev/null +++ b/tests/frontend/specs/button_indentation.js @@ -0,0 +1,179 @@ +describe("indentation button", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + it("indent text", function(done){ + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + var $indentButton = chrome$(".buttonicon-indent"); + $indentButton.click(); + + helper.waitFor(function(){ + return inner$("div").first().find("ul li").length === 1; + }).done(done); + }); + + it("keeps the indent on enter for the new line", function(done){ + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + var $indentButton = chrome$(".buttonicon-indent"); + $indentButton.click(); + + //type a bit, make a line break and type again + var $firstTextElement = inner$("div span").first(); + $firstTextElement.sendkeys('line 1'); + $firstTextElement.sendkeys('{enter}'); + $firstTextElement.sendkeys('line 2'); + $firstTextElement.sendkeys('{enter}'); + + helper.waitFor(function(){ + return inner$("div span").first().text().indexOf("line 2") === -1; + }).done(function(){ + var $newSecondLine = inner$("div").first().next(); + var hasULElement = $newSecondLine.find("ul li").length === 1; + + expect(hasULElement).to.be(true); + expect($newSecondLine.text()).to.be("line 2"); + done(); + }); + }); + + /* + + it("makes text indented and outdented", function() { + + //get the inner iframe + var $inner = testHelper.$getPadInner(); + + //get the first text element out of the inner iframe + var firstTextElement = $inner.find("div").first(); + + //select this text element + testHelper.selectText(firstTextElement[0], $inner); + + //get the indentation button and click it + var $indentButton = testHelper.$getPadChrome().find(".buttonicon-indent"); + $indentButton.click(); + + //ace creates a new dom element when you press a button, so just get the first text element again + var newFirstTextElement = $inner.find("div").first(); + + // is there a list-indent class element now? + var firstChild = newFirstTextElement.children(":first"); + var isUL = firstChild.is('ul'); + + //expect it to be the beginning of a list + expect(isUL).to.be(true); + + var secondChild = firstChild.children(":first"); + var isLI = secondChild.is('li'); + //expect it to be part of a list + expect(isLI).to.be(true); + + //indent again + $indentButton.click(); + + var newFirstTextElement = $inner.find("div").first(); + + // is there a list-indent class element now? + var firstChild = newFirstTextElement.children(":first"); + var hasListIndent2 = firstChild.hasClass('list-indent2'); + + //expect it to be part of a list + expect(hasListIndent2).to.be(true); + + //make sure the text hasn't changed + expect(newFirstTextElement.text()).to.eql(firstTextElement.text()); + + + // test outdent + + //get the unindentation button and click it twice + var $outdentButton = testHelper.$getPadChrome().find(".buttonicon-outdent"); + $outdentButton.click(); + $outdentButton.click(); + + //ace creates a new dom element when you press a button, so just get the first text element again + var newFirstTextElement = $inner.find("div").first(); + + // is there a list-indent class element now? + var firstChild = newFirstTextElement.children(":first"); + var isUL = firstChild.is('ul'); + + //expect it not to be the beginning of a list + expect(isUL).to.be(false); + + var secondChild = firstChild.children(":first"); + var isLI = secondChild.is('li'); + //expect it to not be part of a list + expect(isLI).to.be(false); + + //make sure the text hasn't changed + expect(newFirstTextElement.text()).to.eql(firstTextElement.text()); + + + // Next test tests multiple line indentation + + //select this text element + testHelper.selectText(firstTextElement[0], $inner); + + //indent twice + $indentButton.click(); + $indentButton.click(); + + //get the first text element out of the inner iframe + var firstTextElement = $inner.find("div").first(); + + //select this text element + testHelper.selectText(firstTextElement[0], $inner); + + /* this test creates the below content, both should have double indentation + line1 + line2 + + + firstTextElement.sendkeys('{rightarrow}'); // simulate a keypress of enter + firstTextElement.sendkeys('{enter}'); // simulate a keypress of enter + firstTextElement.sendkeys('line 1'); // simulate writing the first line + firstTextElement.sendkeys('{enter}'); // simulate a keypress of enter + firstTextElement.sendkeys('line 2'); // simulate writing the second line + + //get the second text element out of the inner iframe + setTimeout(function(){ // THIS IS REALLY BAD + var secondTextElement = $('iframe').contents().find('iframe').contents().find('iframe').contents().find('body > div').get(1); // THIS IS UGLY + + // is there a list-indent class element now? + var firstChild = secondTextElement.children(":first"); + var isUL = firstChild.is('ul'); + + //expect it to be the beginning of a list + expect(isUL).to.be(true); + + var secondChild = secondChild.children(":first"); + var isLI = secondChild.is('li'); + //expect it to be part of a list + expect(isLI).to.be(true); + + //get the first text element out of the inner iframe + var thirdTextElement = $('iframe').contents().find('iframe').contents().find('iframe').contents().find('body > div').get(2); // THIS IS UGLY TOO + + // is there a list-indent class element now? + var firstChild = thirdTextElement.children(":first"); + var isUL = firstChild.is('ul'); + + //expect it to be the beginning of a list + expect(isUL).to.be(true); + + var secondChild = firstChild.children(":first"); + var isLI = secondChild.is('li'); + + //expect it to be part of a list + expect(isLI).to.be(true); + },1000); + });*/ +}); diff --git a/tests/frontend/specs/button_italic.js b/tests/frontend/specs/button_italic.js new file mode 100644 index 00000000..fc2e15a7 --- /dev/null +++ b/tests/frontend/specs/button_italic.js @@ -0,0 +1,36 @@ +describe("italic button", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + it("makes text italic", function(done) { + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + //get the first text element out of the inner iframe + var $firstTextElement = inner$("div").first(); + + //select this text element + $firstTextElement.sendkeys('{selectall}'); + + //get the bold button and click it + var $boldButton = chrome$(".buttonicon-italic"); + $boldButton.click(); + + //ace creates a new dom element when you press a button, so just get the first text element again + var $newFirstTextElement = inner$("div").first(); + + // is there a <i> element now? + var isItalic = $newFirstTextElement.find("i").length === 1; + + //expect it to be bold + expect(isItalic).to.be(true); + + //make sure the text hasn't changed + expect($newFirstTextElement.text()).to.eql($firstTextElement.text()); + + done(); + }); +}); diff --git a/tests/frontend/specs/button_ordered_list.js b/tests/frontend/specs/button_ordered_list.js new file mode 100644 index 00000000..ca7d755e --- /dev/null +++ b/tests/frontend/specs/button_ordered_list.js @@ -0,0 +1,47 @@ +describe("assign ordered list", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + it("insert ordered list text", function(done){ + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + var $insertorderedlistButton = chrome$(".buttonicon-insertorderedlist"); + $insertorderedlistButton.click(); + + helper.waitFor(function(){ + return inner$("div").first().find("ol li").length === 1; + }).done(done); + }); + + xit("issue #1125 keeps the numbered list on enter for the new line - EMULATES PASTING INTO A PAD", function(done){ + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + var $insertorderedlistButton = chrome$(".buttonicon-insertorderedlist"); + $insertorderedlistButton.click(); + + //type a bit, make a line break and type again + var $firstTextElement = inner$("div span").first(); + $firstTextElement.sendkeys('line 1'); + $firstTextElement.sendkeys('{enter}'); + $firstTextElement.sendkeys('line 2'); + $firstTextElement.sendkeys('{enter}'); + + helper.waitFor(function(){ + return inner$("div span").first().text().indexOf("line 2") === -1; + }).done(function(){ + var $newSecondLine = inner$("div").first().next(); + var hasOLElement = $newSecondLine.find("ol li").length === 1; + console.log($newSecondLine.find("ol")); + expect(hasOLElement).to.be(true); + expect($newSecondLine.text()).to.be("line 2"); + var hasLineNumber = $newSecondLine.find("ol").attr("start") === 2; + expect(hasLineNumber).to.be(true); // This doesn't work because pasting in content doesn't work + done(); + }); + }); +}); diff --git a/tests/frontend/specs/button_redo.js b/tests/frontend/specs/button_redo.js new file mode 100644 index 00000000..3ce69142 --- /dev/null +++ b/tests/frontend/specs/button_redo.js @@ -0,0 +1,37 @@ +describe("undo button then redo button", function(){ + beforeEach(function(cb){ + helper.newPad(cb); // creates a new pad + this.timeout(60000); + }); + + it("undo some typing", function(done){ + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + // get the first text element inside the editable space + var $firstTextElement = inner$("div span").first(); + var originalValue = $firstTextElement.text(); // get the original value + var newString = "Foo"; + + $firstTextElement.sendkeys(newString); // send line 1 to the pad + var modifiedValue = $firstTextElement.text(); // get the modified value + expect(modifiedValue).not.to.be(originalValue); // expect the value to change + + // get undo and redo buttons + var $undoButton = chrome$(".buttonicon-undo"); + var $redoButton = chrome$(".buttonicon-redo"); + // click the buttons + $undoButton.click(); // removes foo + $redoButton.click(); // resends foo + + helper.waitFor(function(){ + console.log(inner$("div span").first().text()); + return inner$("div span").first().text() === newString; + }).done(function(){ + var finalValue = inner$("div").first().text(); + expect(finalValue).to.be(modifiedValue); // expect the value to change + done(); + }); + }); +}); + diff --git a/tests/frontend/specs/button_strikethrough.js b/tests/frontend/specs/button_strikethrough.js new file mode 100644 index 00000000..9afcea0f --- /dev/null +++ b/tests/frontend/specs/button_strikethrough.js @@ -0,0 +1,36 @@ +describe("strikethrough button", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + it("makes text strikethrough", function(done) { + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + //get the first text element out of the inner iframe + var $firstTextElement = inner$("div").first(); + + //select this text element + $firstTextElement.sendkeys('{selectall}'); + + //get the strikethrough button and click it + var $strikethroughButton = chrome$(".buttonicon-strikethrough"); + $strikethroughButton.click(); + + //ace creates a new dom element when you press a button, so just get the first text element again + var $newFirstTextElement = inner$("div").first(); + + // is there a <i> element now? + var isstrikethrough = $newFirstTextElement.find("s").length === 1; + + //expect it to be strikethrough + expect(isstrikethrough).to.be(true); + + //make sure the text hasn't changed + expect($newFirstTextElement.text()).to.eql($firstTextElement.text()); + + done(); + }); +}); diff --git a/tests/frontend/specs/button_timeslider.js b/tests/frontend/specs/button_timeslider.js new file mode 100644 index 00000000..cb37bacb --- /dev/null +++ b/tests/frontend/specs/button_timeslider.js @@ -0,0 +1,47 @@ +//deactivated, we need a nice way to get the timeslider, this is ugly +xdescribe("timeslider button takes you to the timeslider of a pad", function(){ + beforeEach(function(cb){ + helper.newPad(cb); // creates a new pad + this.timeout(60000); + }); + + it("timeslider contained in URL", function(done){ + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + // get the first text element inside the editable space + var $firstTextElement = inner$("div span").first(); + var originalValue = $firstTextElement.text(); // get the original value + var newValue = "Testing"+originalValue; + $firstTextElement.sendkeys("Testing"); // send line 1 to the pad + + var modifiedValue = $firstTextElement.text(); // get the modified value + expect(modifiedValue).not.to.be(originalValue); // expect the value to change + + helper.waitFor(function(){ + return modifiedValue !== originalValue; // The value has changed so we can.. + }).done(function(){ + + var $timesliderButton = chrome$("#timesliderlink"); + $timesliderButton.click(); // So click the timeslider link + + helper.waitFor(function(){ + var iFrameURL = chrome$.window.location.href; + if(iFrameURL){ + return iFrameURL.indexOf("timeslider") !== -1; + }else{ + return false; // the URL hasnt been set yet + } + }).done(function(){ + // click the buttons + var iFrameURL = chrome$.window.location.href; // get the url + var inTimeslider = iFrameURL.indexOf("timeslider") !== -1; + expect(inTimeslider).to.be(true); // expect the value to change + done(); + }); + + + }); + }); +}); + diff --git a/tests/frontend/specs/button_undo.js b/tests/frontend/specs/button_undo.js new file mode 100644 index 00000000..412b786b --- /dev/null +++ b/tests/frontend/specs/button_undo.js @@ -0,0 +1,33 @@ +describe("undo button", function(){ + beforeEach(function(cb){ + helper.newPad(cb); // creates a new pad + this.timeout(60000); + }); + + it("undo some typing", function(done){ + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + // get the first text element inside the editable space + var $firstTextElement = inner$("div span").first(); + var originalValue = $firstTextElement.text(); // get the original value + + $firstTextElement.sendkeys("foo"); // send line 1 to the pad + var modifiedValue = $firstTextElement.text(); // get the modified value + expect(modifiedValue).not.to.be(originalValue); // expect the value to change + + // get clear authorship button as a variable + var $undoButton = chrome$(".buttonicon-undo"); + // click the button + $undoButton.click(); + + helper.waitFor(function(){ + return inner$("div span").first().text() === originalValue; + }).done(function(){ + var finalValue = inner$("div span").first().text(); + expect(finalValue).to.be(originalValue); // expect the value to change + done(); + }); + }); +}); + diff --git a/tests/frontend/specs/change_user_name.js b/tests/frontend/specs/change_user_name.js new file mode 100644 index 00000000..ba089c90 --- /dev/null +++ b/tests/frontend/specs/change_user_name.js @@ -0,0 +1,72 @@ +describe("change username value", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + it("Remembers the user name after a refresh", function(done) { + this.timeout(60000); + var chrome$ = helper.padChrome$; + + //click on the settings button to make settings visible + var $userButton = chrome$(".buttonicon-showusers"); + $userButton.click(); + + var $usernameInput = chrome$("#myusernameedit"); + $usernameInput.click(); + + $usernameInput.val('John McLear'); + $usernameInput.blur(); + + setTimeout(function(){ //give it a second to save the username on the server side + helper.newPad({ // get a new pad, but don't clear the cookies + clearCookies: false + , cb: function(){ + var chrome$ = helper.padChrome$; + + //click on the settings button to make settings visible + var $userButton = chrome$(".buttonicon-showusers"); + $userButton.click(); + + var $usernameInput = chrome$("#myusernameedit"); + expect($usernameInput.val()).to.be('John McLear') + done(); + } + }); + }, 1000); + }); + + + it("Own user name is shown when you enter a chat", function(done) { + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + //click on the settings button to make settings visible + var $userButton = chrome$(".buttonicon-showusers"); + $userButton.click(); + + var $usernameInput = chrome$("#myusernameedit"); + $usernameInput.click(); + + $usernameInput.val('John McLear'); + $usernameInput.blur(); + + //click on the chat button to make chat visible + var $chatButton = chrome$("#chaticon"); + $chatButton.click(); + var $chatInput = chrome$("#chatinput"); + $chatInput.sendkeys('O hi'); // simulate a keypress of typing JohnMcLear + $chatInput.sendkeys('{enter}'); // simulate a keypress of enter actually does evt.which = 10 not 13 + + //check if chat shows up + helper.waitFor(function(){ + return chrome$("#chattext").children("p").length !== 0; // wait until the chat message shows up + }).done(function(){ + var $firstChatMessage = chrome$("#chattext").children("p"); + var containsJohnMcLear = $firstChatMessage.text().indexOf("John McLear") !== -1; // does the string contain John McLear + expect(containsJohnMcLear).to.be(true); // expect the first chat message to contain JohnMcLear + done(); + }); + }); +}); diff --git a/tests/frontend/specs/chat_always_on_screen.js b/tests/frontend/specs/chat_always_on_screen.js new file mode 100644 index 00000000..4873763f --- /dev/null +++ b/tests/frontend/specs/chat_always_on_screen.js @@ -0,0 +1,40 @@ +describe("chat always ons creen select", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + it("makes chat stick to right side of the screen", function(done) { + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + //click on the settings button to make settings visible + var $settingsButton = chrome$(".buttonicon-settings"); + $settingsButton.click(); + + //get the chat selector + var $stickychatCheckbox = chrome$("#options-stickychat"); + + //select chat always on screen and fire change event + $stickychatCheckbox.attr('selected','selected'); + $stickychatCheckbox.change(); + $stickychatCheckbox.click(); + + //check if chat changed to get the stickychat Class + var $chatbox = chrome$("#chatbox"); + var hasStickyChatClass = $chatbox.hasClass("stickyChat"); + expect(hasStickyChatClass).to.be(true); + + //select chat always on screen and fire change event + $stickychatCheckbox.attr('selected','selected'); + $stickychatCheckbox.change(); + $stickychatCheckbox.click(); + + //check if chat changed to remove the stickychat Class + var hasStickyChatClass = $chatbox.hasClass("stickyChat"); + expect(hasStickyChatClass).to.be(false); + + done(); + }); +}); diff --git a/tests/frontend/specs/embed_value.js b/tests/frontend/specs/embed_value.js new file mode 100644 index 00000000..729cc666 --- /dev/null +++ b/tests/frontend/specs/embed_value.js @@ -0,0 +1,133 @@ +describe("embed links", function(){ + var objectify = function (str) + { + var hash = {}; + var parts = str.split('&'); + for(var i = 0; i < parts.length; i++) + { + var keyValue = parts[i].split('='); + hash[keyValue[0]] = keyValue[1]; + } + return hash; + } + + var checkiFrameCode = function(embedCode, readonly){ + //turn the code into an html element + var $embediFrame = $(embedCode); + + //read and check the frame attributes + var width = $embediFrame.attr("width"); + var height = $embediFrame.attr("height"); + var name = $embediFrame.attr("name"); + expect(width).to.be('600'); + expect(height).to.be('400'); + expect(name).to.be(readonly ? "embed_readonly" : "embed_readwrite"); + + //parse the url + var src = $embediFrame.attr("src"); + var questionMark = src.indexOf("?"); + var url = src.substr(0,questionMark); + var paramsStr = src.substr(questionMark+1); + var params = objectify(paramsStr); + + var expectedParams = { + showControls: 'true' + , showChat: 'true' + , showLineNumbers: 'true' + , useMonospaceFont: 'false' + } + + //check the url + if(readonly){ + expect(url.indexOf("r.") > 0).to.be(true); + } else { + expect(url).to.be(helper.padChrome$.window.location.href); + } + + //check if all parts of the url are like expected + expect(params).to.eql(expectedParams); + } + + describe("read and write", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + describe("the share link", function(){ + it("is the actual pad url", function(done){ + var chrome$ = helper.padChrome$; + + //open share dropdown + chrome$(".buttonicon-embed").click(); + + //get the link of the share field + the actual pad url and compare them + var shareLink = chrome$("#linkinput").val(); + var padURL = chrome$.window.location.href; + expect(shareLink).to.be(padURL); + + done(); + }); + }); + + describe("the embed as iframe code", function(){ + it("is an iframe with the the correct url parameters and correct size", function(done){ + var chrome$ = helper.padChrome$; + + //open share dropdown + chrome$(".buttonicon-embed").click(); + + //get the link of the share field + the actual pad url and compare them + var embedCode = chrome$("#embedinput").val(); + + checkiFrameCode(embedCode, false) + + done(); + }); + }); + }); + + describe("when read only option is set", function(){ + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + describe("the share link", function(){ + it("shows a read only url", function(done){ + var chrome$ = helper.padChrome$; + + //open share dropdown + chrome$(".buttonicon-embed").click(); + //check read only checkbox, a bit hacky + chrome$('#readonlyinput').attr('checked','checked').click().attr('checked','checked'); + + //get the link of the share field + the actual pad url and compare them + var shareLink = chrome$("#linkinput").val(); + var containsReadOnlyLink = shareLink.indexOf("r.") > 0 + expect(containsReadOnlyLink).to.be(true); + + done(); + }); + }); + + describe("the embed as iframe code", function(){ + it("is an iframe with the the correct url parameters and correct size", function(done){ + var chrome$ = helper.padChrome$; + + //open share dropdown + chrome$(".buttonicon-embed").click(); + //check read only checkbox, a bit hacky + chrome$('#readonlyinput').attr('checked','checked').click().attr('checked','checked'); + + //get the link of the share field + the actual pad url and compare them + var embedCode = chrome$("#embedinput").val(); + + checkiFrameCode(embedCode, true); + + done(); + }); + }); + }); +}); diff --git a/tests/frontend/specs/font_type.js b/tests/frontend/specs/font_type.js new file mode 100644 index 00000000..af90b865 --- /dev/null +++ b/tests/frontend/specs/font_type.js @@ -0,0 +1,30 @@ +describe("font select", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + it("makes text monospace", function(done) { + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + //click on the settings button to make settings visible + var $settingsButton = chrome$(".buttonicon-settings"); + $settingsButton.click(); + + //get the font menu and monospace option + var $viewfontmenu = chrome$("#viewfontmenu"); + var $monospaceoption = $viewfontmenu.find("[value=monospace]"); + + //select monospace and fire change event + $monospaceoption.attr('selected','selected'); + $viewfontmenu.change(); + + //check if font changed to monospace + var fontFamily = inner$("body").css("font-family").toLowerCase(); + expect(fontFamily).to.be("monospace"); + + done(); + }); +}); diff --git a/tests/frontend/specs/helper.js b/tests/frontend/specs/helper.js new file mode 100644 index 00000000..621b3c3a --- /dev/null +++ b/tests/frontend/specs/helper.js @@ -0,0 +1,99 @@ +describe("the test helper", function(){ + describe("the newPad method", function(){ + xit("doesn't leak memory if you creates iframes over and over again", function(done){ + this.timeout(100000); + + var times = 10; + + var loadPad = function(){ + helper.newPad(function(){ + times--; + if(times > 0){ + loadPad(); + } else { + done(); + } + }) + } + + loadPad(); + }); + + it("gives me 3 jquery instances of chrome, outer and inner", function(done){ + this.timeout(5000); + + helper.newPad(function(){ + //check if the jquery selectors have the desired elements + expect(helper.padChrome$("#editbar").length).to.be(1); + expect(helper.padOuter$("#outerdocbody").length).to.be(1); + expect(helper.padInner$("#innerdocbody").length).to.be(1); + + //check if the document object was set correctly + expect(helper.padChrome$.window.document).to.be(helper.padChrome$.document); + expect(helper.padOuter$.window.document).to.be(helper.padOuter$.document); + expect(helper.padInner$.window.document).to.be(helper.padInner$.document); + + done(); + }); + }); + }); + + describe("the waitFor method", function(){ + it("takes a timeout and waits long enough", function(done){ + this.timeout(2000); + var startTime = new Date().getTime(); + + helper.waitFor(function(){ + return false; + }, 1500).fail(function(){ + var duration = new Date().getTime() - startTime; + expect(duration).to.be.greaterThan(1400); + done(); + }); + }); + + it("takes an interval and checks on every interval", function(done){ + this.timeout(4000); + var checks = 0; + + helper.waitFor(function(){ + checks++; + return false; + }, 2000, 100).fail(function(){ + expect(checks).to.be.greaterThan(10); + expect(checks).to.be.lessThan(30); + done(); + }); + }); + + describe("returns a deferred object", function(){ + it("it calls done after success", function(done){ + helper.waitFor(function(){ + return true; + }).done(function(){ + done(); + }); + }); + + it("calls fail after failure", function(done){ + helper.waitFor(function(){ + return false; + },0).fail(function(){ + done(); + }); + }); + + xit("throws if you don't listen for fails", function(done){ + var onerror = window.onerror; + window.onerror = function(){ + window.onerror = onerror; + done(); + } + + helper.waitFor(function(){ + return false; + },100); + }); + }); + }); +}); \ No newline at end of file diff --git a/tests/frontend/specs/keystroke_chat.js b/tests/frontend/specs/keystroke_chat.js new file mode 100644 index 00000000..d6a7d2fd --- /dev/null +++ b/tests/frontend/specs/keystroke_chat.js @@ -0,0 +1,39 @@ +describe("send chat message", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + it("opens chat, sends a message and makes sure it exists on the page", function(done) { + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + var chatValue = "JohnMcLear"; + + //click on the chat button to make chat visible + var $chatButton = chrome$("#chaticon"); + $chatButton.click(); + var $chatInput = chrome$("#chatinput"); + $chatInput.sendkeys('JohnMcLear'); // simulate a keypress of typing JohnMcLear + $chatInput.sendkeys('{enter}'); // simulate a keypress of enter actually does evt.which = 10 not 13 + + //check if chat shows up + helper.waitFor(function(){ + return chrome$("#chattext").children("p").length !== 0; // wait until the chat message shows up + }).done(function(){ + var $firstChatMessage = chrome$("#chattext").children("p"); + var containsMessage = $firstChatMessage.text().indexOf("JohnMcLear") !== -1; // does the string contain JohnMcLear? + expect(containsMessage).to.be(true); // expect the first chat message to contain JohnMcLear + + // do a slightly more thorough check + var username = $firstChatMessage.children("b"); + var usernameValue = username.text(); + var time = $firstChatMessage.children(".time"); + var timeValue = time.text(); + var expectedStringIncludingUserNameAndTime = usernameValue + timeValue + " " + "JohnMcLear"; + expect(expectedStringIncludingUserNameAndTime).to.be($firstChatMessage.text()); + done(); + }); + + }); +}); diff --git a/tests/frontend/specs/keystroke_delete.js b/tests/frontend/specs/keystroke_delete.js new file mode 100644 index 00000000..86e76f56 --- /dev/null +++ b/tests/frontend/specs/keystroke_delete.js @@ -0,0 +1,37 @@ +describe("delete keystroke", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + it("makes text delete", function(done) { + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + //get the first text element out of the inner iframe + var $firstTextElement = inner$("div").first(); + + // get the original length of this element + var elementLength = $firstTextElement.text().length; + + // get the original string value minus the last char + var originalTextValue = $firstTextElement.text(); + originalTextValueMinusFirstChar = originalTextValue.substring(1, originalTextValue.length ); + + // simulate key presses to delete content + $firstTextElement.sendkeys('{leftarrow}'); // simulate a keypress of the left arrow key + $firstTextElement.sendkeys('{del}'); // simulate a keypress of delete + + //ace creates a new dom element when you press a keystroke, so just get the first text element again + var $newFirstTextElement = inner$("div").first(); + + // get the new length of this element + var newElementLength = $newFirstTextElement.text().length; + + //expect it to be one char less in length + expect(newElementLength).to.be((elementLength-1)); + + done(); + }); +}); diff --git a/tests/frontend/specs/keystroke_enter.js b/tests/frontend/specs/keystroke_enter.js new file mode 100644 index 00000000..e46b1d2f --- /dev/null +++ b/tests/frontend/specs/keystroke_enter.js @@ -0,0 +1,34 @@ +describe("enter keystroke", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + it("creates a enw line & puts cursor onto a new line", function(done) { + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + //get the first text element out of the inner iframe + var $firstTextElement = inner$("div").first(); + + // get the original string value minus the last char + var originalTextValue = $firstTextElement.text(); + + // simulate key presses to enter content + $firstTextElement.sendkeys('{enter}'); + + //ace creates a new dom element when you press a keystroke, so just get the first text element again + var $newFirstTextElement = inner$("div").first(); + + helper.waitFor(function(){ + return inner$("div").first().text() === ""; + }).done(function(){ + var $newSecondLine = inner$("div").first().next(); + var newFirstTextElementValue = inner$("div").first().text(); + expect(newFirstTextElementValue).to.be(""); // expect the first line to be blank + expect($newSecondLine.text()).to.be(originalTextValue); // expect the second line to be the same as the original first line. + done(); + }); + }); +}); diff --git a/tests/frontend/specs/keystroke_urls_become_clickable.js b/tests/frontend/specs/keystroke_urls_become_clickable.js new file mode 100644 index 00000000..2a46360e --- /dev/null +++ b/tests/frontend/specs/keystroke_urls_become_clickable.js @@ -0,0 +1,24 @@ +describe("urls", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + it("when you enter an url, it becomes clickable", function(done) { + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + //get the first text element out of the inner iframe + var firstTextElement = inner$("div").first(); + + // simulate key presses to delete content + firstTextElement.sendkeys('{selectall}'); // select all + firstTextElement.sendkeys('{del}'); // clear the first line + firstTextElement.sendkeys('http://etherpad.org'); // insert a URL + + helper.waitFor(function(){ + return inner$("div").first().find("a").length === 1; + }, 2000).done(done); + }); +}); diff --git a/tests/frontend/specs/language.js b/tests/frontend/specs/language.js new file mode 100644 index 00000000..83bb8458 --- /dev/null +++ b/tests/frontend/specs/language.js @@ -0,0 +1,81 @@ +describe("Language select and change", function(){ + //create a new pad before each test run + beforeEach(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + it("makes text german", function(done) { + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + //click on the settings button to make settings visible + var $settingsButton = chrome$(".buttonicon-settings"); + $settingsButton.click(); + + //click the language button + var $language = chrome$("#languagemenu"); + var $languageoption = $language.find("[value=de]"); + + //select german + $languageoption.attr('selected','selected'); + $language.change(); + + var localizedEventFired = false; + $(chrome$.window).bind('localized', function() { + localizedEventFired = true; + }) + + helper.waitFor(function() { return localizedEventFired;}) + .done(function(){ + //get the value of the bold button + var $boldButton = chrome$(".buttonicon-bold").parent(); + + //get the title of the bold button + var boldButtonTitle = $boldButton[0]["title"]; + + //check if the language is now german + expect(boldButtonTitle).to.be("Fett (Strg-B)"); + done(); + }); + }); + + it("makes text English", function(done) { + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + //click on the settings button to make settings visible + var $settingsButton = chrome$(".buttonicon-settings"); + $settingsButton.click(); + + //click the language button + var $language = chrome$("#languagemenu"); + var $languageoption = $language.find("[value=en]"); + + //select german + $languageoption.attr('selected','selected'); + $language.change(); + + var localizedEventFired = false; + $(chrome$.window).bind('localized', function() { + localizedEventFired = true; + }) + + helper.waitFor(function() { return localizedEventFired;}) + .done(function(){ + + //get the value of the bold button + var $boldButton = chrome$(".buttonicon-bold").parent(); + + //get the title of the bold button + var boldButtonTitle = $boldButton[0]["title"]; + + //check if the language is now English + expect(boldButtonTitle).to.be("Bold (Ctrl-B)"); + done(); + + }); + }); + +}); + diff --git a/tests/frontend/travis/.gitignore b/tests/frontend/travis/.gitignore new file mode 100644 index 00000000..68284f67 --- /dev/null +++ b/tests/frontend/travis/.gitignore @@ -0,0 +1,2 @@ +sauce_connect.log +sauce_connect.log.* diff --git a/tests/frontend/travis/remote_runner.js b/tests/frontend/travis/remote_runner.js new file mode 100644 index 00000000..a4f1dac1 --- /dev/null +++ b/tests/frontend/travis/remote_runner.js @@ -0,0 +1,110 @@ +var srcFolder = "../../../src/node_modules/"; +var wd = require(srcFolder + "wd"); +var async = require(srcFolder + "async"); + +var config = { + host: "ondemand.saucelabs.com" + , port: 80 + , username: process.env.SAUCE_USER + , accessKey: process.env.SAUCE_KEY +} + +var allTestsPassed = true; + +var sauceTestWorker = async.queue(function (testSettings, callback) { + var browser = wd.remote(config.host, config.port, config.username, config.accessKey); + var browserChain = browser.chain(); + var name = process.env.GIT_HASH + " - " + testSettings.browserName + " " + testSettings.version + ", " + testSettings.platform; + testSettings.name = name; + testSettings["public"] = true; + testSettings["build"] = process.env.GIT_HASH; + + browserChain.init(testSettings).get("http://localhost:9001/tests/frontend/", function(){ + var url = "https://saucelabs.com/jobs/" + browser.sessionID; + console.log("Remote sauce test '" + name + "' started! " + url); + + //tear down the test excecution + var stopSauce = function(success){ + getStatusInterval && clearInterval(getStatusInterval); + clearTimeout(timeout); + + browserChain.quit(); + + if(!success){ + allTestsPassed = false; + } + + var testResult = knownConsoleText.replace(/\[red\]/g,'\x1B[31m').replace(/\[yellow\]/g,'\x1B[33m') + .replace(/\[green\]/g,'\x1B[32m').replace(/\[clear\]/g, '\x1B[39m'); + testResult = testResult.split("\\n").map(function(line){ + return "[" + testSettings.browserName + (testSettings.version === "" ? '' : (" " + testSettings.version)) + "] " + line; + }).join("\n"); + + console.log(testResult); + console.log("Remote sauce test '" + name + "' finished! " + url); + + callback(); + } + + //timeout for the case the test hangs + var timeout = setTimeout(function(){ + stopSauce(false); + }, 60000 * 10); + + var knownConsoleText = ""; + var getStatusInterval = setInterval(function(){ + browserChain.eval("$('#console').text()", function(err, consoleText){ + if(!consoleText || err){ + return; + } + knownConsoleText = consoleText; + + if(knownConsoleText.indexOf("FINISHED") > 0){ + var success = knownConsoleText.indexOf("FAILED") === -1; + stopSauce(success); + } + }); + }, 5000); + }); +}, 5); //run 5 tests in parrallel + +// Firefox +sauceTestWorker.push({ + 'platform' : 'Linux' + , 'browserName' : 'firefox' + , 'version' : '' +}); + +// Chrome +sauceTestWorker.push({ + 'platform' : 'Linux' + , 'browserName' : 'googlechrome' + , 'version' : '' +}); + +// IE 8 +sauceTestWorker.push({ + 'platform' : 'Windows 2003' + , 'browserName' : 'iexplore' + , 'version' : '8' +}); + +// IE 9 +sauceTestWorker.push({ + 'platform' : 'Windows 2008' + , 'browserName' : 'iexplore' + , 'version' : '9' +}); + +// IE 10 +sauceTestWorker.push({ + 'platform' : 'Windows 2012' + , 'browserName' : 'iexplore' + , 'version' : '10' +}); + +sauceTestWorker.drain = function() { + setTimeout(function(){ + process.exit(allTestsPassed ? 0 : 1); + }, 3000); +} \ No newline at end of file diff --git a/tests/frontend/travis/runner.sh b/tests/frontend/travis/runner.sh new file mode 100755 index 00000000..ae53e667 --- /dev/null +++ b/tests/frontend/travis/runner.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +#Move to the base folder +cd `dirname $0` + +#start etherpad lite +../../../bin/run.sh > /dev/null & +sleep 10 + +#start remote runner +node remote_runner.js +exit_code=$? + +kill $! +kill $(cat /tmp/sauce.pid) +sleep 30 + +exit $exit_code \ No newline at end of file diff --git a/tests/frontend/travis/sauce_tunnel.sh b/tests/frontend/travis/sauce_tunnel.sh new file mode 100755 index 00000000..ac8f7ac7 --- /dev/null +++ b/tests/frontend/travis/sauce_tunnel.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# download and unzip the sauce connector +curl http://saucelabs.com/downloads/Sauce-Connect-latest.zip > /tmp/sauce.zip +unzip /tmp/sauce.zip -d /tmp + +# start the sauce connector in background and make sure it doesn't output the secret key +(java -jar /tmp/Sauce-Connect.jar $SAUCE_USER $SAUCE_KEY -f /tmp/tunnel > /dev/null )& + +# save the sauce pid in a file +echo $! > /tmp/sauce.pid + +# wait for the tunnel to build up +while [ ! -e "/tmp/tunnel" ] + do + sleep 1 +done \ No newline at end of file