refactor(playground/client): break up playground.js into several modules.
Separate concerns by breaking up client/playground.js into several modules and
and mercury components.
* Split out router logic into a generic and isolated module.
* Use hashbang urls for routing
* Add special view helper for generating anchors to trigger route changes.
* The entry point (browser/index.js) sets up state and router->state glue.
* browser/api.js holds all logic dealing with remote service interaction.
* Add a single place for URL processing/templating in the api module.
* bundles are lazily updated and rendered immediately when available.
* Refactor streaming response handling from run requests.
* Add new repo and bug links to package.json
* Scroll handle split up into individual components.
* UI: dropped the reset button for now
Open/pending issues have been dropped into veyron/release-issues#1890
Related veyron/release-issues#802
Close veyron/release-issues#1558
Change-Id: I55cd0a38c01d57132cca951cffa151bc90ea97cb
diff --git a/client/.gitignore b/client/.gitignore
index 35cb0b7..1df0517 100644
--- a/client/.gitignore
+++ b/client/.gitignore
@@ -2,6 +2,7 @@
bundles/*/*/pkg
public/bundles
bundle.*
+!browser/components/bundle.js
bundle_*.json
node_modules
npm-debug.log
diff --git a/client/.jshintrc b/client/.jshintrc
index 8837eae..e6bb35f 100644
--- a/client/.jshintrc
+++ b/client/.jshintrc
@@ -19,10 +19,6 @@
"undef": true,
"unused": "vars",
- "browser": true,
"devel": true,
- "node": true,
-
- "globals": {
- }
+ "node": true
}
diff --git a/client/Makefile b/client/Makefile
index 5b65b96..d446ba2 100644
--- a/client/Makefile
+++ b/client/Makefile
@@ -77,10 +77,22 @@
@$(RM) -rf public/bundles
@$(RM) -rf $(shell find bundles -name "bundle*.json")
+.PHONY: dependency-check
+dependency-check: package.json node_modules
+ dependency-check $<
+
.PHONY: lint
-lint:
- @jshint .
+lint: dependency-check
+ jshint .
.PHONY: test
test:
v23 run ./test.sh
+
+.PHONY: test-client
+test-client: node_modules
+ tape test/index.js
+
+.PHONY: test-browser
+test-browser: node_modules
+ run-browser test/index.js --phantom
diff --git a/client/browser/api/index.js b/client/browser/api/index.js
new file mode 100644
index 0000000..899d0fe
--- /dev/null
+++ b/client/browser/api/index.js
@@ -0,0 +1,250 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('api');
+var request = require('superagent');
+var hyperquest = require('hyperquest');
+var parallel = require('run-parallel');
+var format = require('format');
+var normalize = require('./normalize');
+var url = require('url');
+var extend = require('xtend');
+var prr = require('prr');
+var config = require('../config');
+var JSONStream = require('./json-stream');
+var split = require('split');
+var defaults = {
+ // Timeout for HTTP requests, 5 secs in milliseconds.
+ timeout: 5 * 60 * 1000,
+ // Temporarily default to the staging load balancer until bundle lists are
+ // available from the API.
+ url: 'http://104.197.24.229:8181/',
+ debug: false
+};
+
+var options = {};
+
+// If options have been defined by a developer via the console use them to
+// override the defaults.
+if (config('api-url')) {
+ options.url = config('api-url');
+}
+
+if (config('api-debug')) {
+ options.debug = config('api-debug');
+}
+
+// Create a singleton instance of the API object which can be shared across
+// modules and components.
+module.exports = API(options);
+
+function API(options) {
+ if (!(this instanceof API)) {
+ return new API(options);
+ }
+
+ options = options || {};
+
+ var api = this;
+
+ api.options = extend(defaults, options);
+
+ debug('initializing with options: %o', api.options);
+
+ prr(api, '_url', url.parse(api.options.url));
+ prr(api, '_pending', []);
+}
+
+API.prototype.url = function(options) {
+ options = options || {};
+
+ // By default return a formatted url string.
+ options = extend({ format: true }, options);
+
+ var api = this;
+ var clone = extend(api._url);
+
+ // Append trailing slash if it's missing
+ if (! clone.pathname.match(/\/$/)) {
+ clone.pathname = clone.pathname + '/'
+ }
+
+ // NOTE: paying attention to options.action is temporary until the API gets
+ // cleaned up and becomes RESTful making the url templating simpler.
+ switch (options.action) {
+ case 'create':
+ clone.pathname = url.resolve(clone.pathname, 'save');
+ break;
+ case 'read':
+ clone.pathname = url.resolve(clone.pathname, 'load');
+ break;
+ case 'run':
+ clone.pathname = url.resolve(clone.pathname, 'compile');
+ break;
+ }
+
+ if (options.uuid) {
+ clone.query = { id: encodeURIComponent(options.uuid) };
+ }
+
+ if (api.options.debug) {
+ clone.query = clone.query || {};
+ clone.query.debug = 1;
+ }
+
+ if (options.format) {
+ return url.format(clone);
+ } else {
+ return clone;
+ }
+};
+
+// Temporary way to list bundles until there is an API endpoint to hit.
+API.prototype.bundles = function(callback) {
+ var api = this;
+ // TODO(jasoncampbell): remove this list once a list API endpoint is
+ // available.
+ var ids = [
+ '_39bce22c2acd70cd235ad2764738f3ffcaec7a5fea67ec4c14a674b991a373c',
+ '_8e15dfe13cf1288ace5f58b0c35a77015be2b23b61ca673a497c7d50afedf3b',
+ '_de64000da6c89b19f566135e7459557fdd3be1f6f4b6e74f76c60f2693fceb1',
+ '_e9a1eb094d30d3d7602f561610f155e29e3d0c9d875116657a65415937e1137'
+ ];
+
+ var workers = ids.map(createWorker);
+
+ // Request all ids in parallel.
+ parallel(workers, callback);
+
+ function createWorker(id) {
+ return worker;
+
+ function worker(cb) {
+ api.get(id, cb);
+ }
+ }
+};
+
+API.prototype.get = function(uuid, callback) {
+ var api = this;
+ var uri = api.url({ uuid: uuid, action: 'read' });
+
+ request
+ .get(uri)
+ .accept('json')
+ .timeout(api.options.timeout)
+ .end(onget);
+
+ function onget(err, res, body) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (! res.ok) {
+ var message = format('GET %s - %s NOT OK', uri, res.statusCode);
+ err = new Error(message);
+ return callback(err);
+ }
+
+ var data = normalize(res.body);
+
+ callback(null, data);
+ }
+};
+
+API.prototype.create = function(data, callback) {
+ var api = this;
+ var uri = api.url({ action: 'create' });
+
+ request
+ .post(uri)
+ .type('json')
+ .accept('json')
+ .timeout(api.options.timeout)
+ .send(data)
+ .end(oncreate);
+
+ function oncreate(err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (! res.ok) {
+ var message = format('POST %s - %s NOT OK', uri, res.status);
+ err = new Error(message);
+ return callback(err);
+ }
+
+ var data = normalize(res.body);
+
+ callback(null, data);
+ }
+};
+
+API.prototype.isPending = function(uuid) {
+ return this._pending.indexOf(uuid) >= 0;
+};
+
+API.prototype.pending = function(uuid) {
+ return this._pending.push(uuid);
+};
+
+API.prototype.done = function(uuid) {
+ var api = this;
+ var start = api._pending.indexOf(uuid);
+ var deleteCount = 1;
+
+ return api._pending.splice(start, deleteCount);
+};
+
+// Initializes an http request and returns a stream.
+//
+// TODO(jasoncampbell): Drop the callback API and return the stream
+// immediately.
+// TODO(jasoncampbell): stop pending xhr
+// SEE: https://github.com/veyron/release-issues/issues/1890
+API.prototype.run = function(data, callback) {
+ var api = this;
+ var uuid = data.uuid;
+ var uri = api.url({ action: 'run' });
+
+ if (api.isPending(uuid)) {
+ var message = format('%s is already running');
+ var err = new Error(message);
+ return callback(err);
+ }
+
+ api.pending(uuid);
+
+ var options = {
+ withCredentials: false,
+ headers: {
+ 'accept': 'application/json',
+ 'content-type': 'application/json'
+ }
+ };
+
+ // TODO(jasoncampbell): Consolidate http libraries.
+ // TODO(jasoncampbell): Verify XHR timeout logic and handle appropriately.
+ var req = hyperquest.post(uri, options);
+
+ req.once('error', callback);
+
+ var stream = JSONStream(); // jshint ignore:line
+
+ stream.on('end', function() {
+ api.done(uuid);
+ });
+
+ req
+ .pipe(split())
+ .pipe(stream);
+
+ callback(null, stream);
+
+ var string = JSON.stringify(data);
+
+ req.write(string);
+ req.end();
+};
diff --git a/client/browser/api/json-stream.js b/client/browser/api/json-stream.js
new file mode 100644
index 0000000..6436c67
--- /dev/null
+++ b/client/browser/api/json-stream.js
@@ -0,0 +1,28 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var through2 = require('through2');
+
+module.exports = create;
+
+function create() {
+ return through2.obj(write);
+}
+
+function write(buffer, enc, callback) {
+ if (buffer.length === 0) {
+ return callback();
+ }
+
+ var json;
+ var err;
+
+ try {
+ json = JSON.parse(buffer);
+ } catch (err) {
+ err.data = buffer;
+ }
+
+ callback(err, json);
+}
diff --git a/client/browser/api/normalize.js b/client/browser/api/normalize.js
new file mode 100644
index 0000000..b2998f2
--- /dev/null
+++ b/client/browser/api/normalize.js
@@ -0,0 +1,16 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+module.exports = normalize;
+
+// TODO: Update once the API returns the correct data structures.
+// map old data format to a new one and return a bundle state object.
+function normalize(old) {
+ var data = JSON.parse(old.Data);
+
+ return {
+ uuid: old.Link,
+ files: data.files
+ };
+}
diff --git a/client/browser/components/bundle/index.js b/client/browser/components/bundle/index.js
new file mode 100644
index 0000000..b49ba5b
--- /dev/null
+++ b/client/browser/components/bundle/index.js
@@ -0,0 +1,136 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('components:bundle:state');
+var hg = require('mercury');
+var log = require('../log');
+var api = require('../../api');
+var resultsConsole = require('../results-console');
+var router = require('../../router');
+var toArray = require('../../util').toArray;
+
+module.exports = bundle;
+module.exports.render = require('./render');
+
+function bundle(json) {
+ var state = hg.state({
+ uuid: hg.value(json.uuid),
+ files: hg.varhash({}),
+ tab: hg.value(json.files[0].name),
+ running: hg.value(false),
+ reset: hg.value(false),
+ resultsConsole: resultsConsole(),
+ channels: {
+ tab: tab,
+ run: run,
+ stop: stop,
+ save: save,
+ fileChange: fileChange,
+ }
+ });
+
+ // Loop thorugh the files array in the `json` object and update the
+ // `state.files` varhash.
+ var length = json.files.length;
+
+ for (var i = 0; i < length; i++) {
+ var file = json.files[i];
+ // NOTE: hg.varhash has a bug where certain keys such as "delete" can not
+ // be used ( https://github.com/nrw/observ-varhash/issues/2 ), once we
+ // start allowing user's to create thier own files we will need to
+ // accomodate the possibility of running into naming collisions for
+ // "name", "get", "put", "delete" by possibily normalizing the keys with a
+ // prefix.
+ state.files.put(file.name, file);
+ }
+
+ // When the console is running update the resultsConsole state.
+ state.running(function update(running) {
+ debug('run changed: %s', running);
+
+ // If running clear previous logs and open the console.
+ if (running) {
+ state.resultsConsole.logs.set(hg.array([]));
+ state.resultsConsole.open.set(true);
+ state.resultsConsole.follow.set(true);
+ }
+ });
+
+ return state;
+}
+
+// When a file's contents change via the editor update the state.
+function fileChange(state, data) {
+ var current = state.files.get(data.name);
+
+ if (current.body !== data.body) {
+ state.files.put(data.name, data);
+ }
+}
+
+// Change the current tab.
+function tab(state, data) {
+ state.tab.set(data.tab);
+}
+
+// "stop" the code run. This will hide the console...
+function stop(state, data) {
+ state.running.set(false);
+ // TODO(jasoncampbell): stop pending xhr.
+}
+
+// Doesn't "save" in a normal sense as the request generates a new resource.
+// This is more like a "Fork" but currently the only thing we have that might
+// resemble saving.
+function save(state) {
+ var data = {
+ files: toArray(state.files())
+ };
+
+ api.create(data, function(err, data) {
+ if (err) {
+ // TODO(jasoncampbell): handle error appropriately.
+ //
+ // SEE: https://github.com/veyron/release-issues/issues/1890
+ throw err;
+ }
+
+ // Since a new id is generated and the id is in the url the router needs
+ // to be updated.
+ //
+ // TODO(jasoncampbell): put the the value of the new bundle here, maybe don't trigger a
+ // reload.
+ router.href.set(data.uuid);
+ });
+}
+
+// Run the code remotely.
+function run(state) {
+ debug('running');
+ state.running.set(true);
+
+ var data = {
+ uuid: state.uuid(),
+ files: toArray(state.files())
+ };
+
+ api.run(data, function onrun(err, stream) {
+ if (err) {
+ // TODO(jasoncampbell): handle error appropriately.
+ throw err;
+ }
+
+ stream.on('error', function onerror(err) {
+ throw err;
+ });
+
+ stream.on('data', function ondata(data) {
+ state.resultsConsole.logs.push(log(data));
+ });
+
+ stream.on('end', function() {
+ state.running.set(false);
+ });
+ });
+}
diff --git a/client/browser/components/bundle/render.js b/client/browser/components/bundle/render.js
new file mode 100644
index 0000000..1bb65dd
--- /dev/null
+++ b/client/browser/components/bundle/render.js
@@ -0,0 +1,102 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('components:bundle:render');
+var anchor = require('../../router/anchor');
+var h = require('mercury').h;
+var hg = require('mercury');
+var results = require('../results-console');
+var toArray = require('../../util').toArray;
+
+module.exports = render;
+
+function render(state) {
+ debug('update %o', state);
+
+ if (! state) {
+ return h('.bundle', [
+ h('p', 'Loading...')
+ ]);
+ }
+
+ return h('.bundle', [
+ h('.pg', [
+ hg.partial(anchorbar, state, state.channels),
+ hg.partial(tabs, state, state.channels),
+ hg.partial(controls, state, state.channels),
+ hg.partial(editors, state, state.channels),
+ hg.partial(results.render,
+ state.resultsConsole,
+ state.resultsConsole.channels)
+ ])
+ ]);
+}
+
+var options = { preventDefault: true };
+
+function anchorbar(state, channels) {
+ return h('.widget-bar', [
+ h('p', [
+ h('strong', 'anchor:'),
+ anchor({ href: '/' + state.uuid }, state.uuid)
+ ])
+ ]);
+}
+
+function tabs(state, channels) {
+ var files = toArray(state.files);
+
+ return h('span.tabs', files.map(tab, state));
+}
+
+function tab(file, index, array) {
+ var state = this;
+ var channels = state.channels;
+
+ return h('span.tab', {
+ className: state.tab === file.name ? 'active' : '',
+ 'ev-click': hg.sendClick(channels.tab, { tab: file.name }, options)
+ }, file.name);
+}
+
+function controls(state, channels) {
+ return h('span.btns', [
+ hg.partial(runButton, state, channels),
+ h('button.btn', {
+ 'ev-click': hg.sendClick(channels.save)
+ }, 'Save')
+ ]);
+}
+
+function runButton(state, channels) {
+ var text = 'Run Code';
+ var sink = channels.run;
+
+ if (state.running) {
+ text = 'Stop';
+ sink = channels.stop;
+ }
+
+ return h('button.btn', {
+ 'ev-click': hg.sendClick(sink)
+ }, text);
+}
+
+// TODO(jasoncampbell): It makes sense to break the editor into it's own
+// component as we will be adding features...
+var aceWidget = require('../../widgets/ace-widget');
+var aceChange = require('../../event-handlers/ace-change');
+
+function editors(state, channels) {
+ var files = toArray(state.files);
+
+ return h('.editors', files.map(function(file) {
+ return h('.editor', {
+ className: (state.tab === file.name ? 'active' : ''),
+ 'ev-ace-change': aceChange(state.channels.fileChange),
+ }, [
+ aceWidget(file)
+ ]);
+ }));
+}
diff --git a/client/browser/components/bundles/index.js b/client/browser/components/bundles/index.js
new file mode 100644
index 0000000..041674f
--- /dev/null
+++ b/client/browser/components/bundles/index.js
@@ -0,0 +1,12 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+
+module.exports = bundles;
+module.exports.render = require('./render');
+
+function bundles() {
+ return hg.varhash({});
+}
diff --git a/client/browser/components/bundles/render.js b/client/browser/components/bundles/render.js
new file mode 100644
index 0000000..3349a00
--- /dev/null
+++ b/client/browser/components/bundles/render.js
@@ -0,0 +1,28 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('components:bundles');
+var h = require('mercury').h;
+var anchor = require('../../router/anchor');
+var toArray = require('../../util').toArray;
+
+module.exports = render;
+
+function render(state) {
+ debug('update %o', state);
+
+ var bundles = toArray(state);
+
+ if (bundles.length === 0) {
+ return h('p', 'Loading...');
+ } else {
+ return h('ul.bundles', bundles.map(li));
+ }
+}
+
+function li(bundle) {
+ return h('li', [
+ anchor({ href: '/' + bundle.uuid }, bundle.uuid)
+ ]);
+}
diff --git a/client/browser/components/header.js b/client/browser/components/header.js
new file mode 100644
index 0000000..2727cd6
--- /dev/null
+++ b/client/browser/components/header.js
@@ -0,0 +1,15 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var h = require('mercury').h;
+
+module.exports = {
+ render: render
+};
+
+function render(state, chanels) {
+ return h('header', [
+ h('h1', state.title)
+ ]);
+}
diff --git a/client/browser/components/log/index.js b/client/browser/components/log/index.js
new file mode 100644
index 0000000..f6c73e1
--- /dev/null
+++ b/client/browser/components/log/index.js
@@ -0,0 +1,22 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+var normalize = require('./normalize');
+
+module.exports = log;
+module.exports.render = require('./render');
+
+function log(data) {
+ data = normalize(data);
+
+ var state = hg.struct({
+ message: data.message,
+ file: data.file,
+ stream: data.stream,
+ timestamp: data.timestamp
+ });
+
+ return state;
+}
diff --git a/client/browser/components/log/normalize.js b/client/browser/components/log/normalize.js
new file mode 100644
index 0000000..11ccd37
--- /dev/null
+++ b/client/browser/components/log/normalize.js
@@ -0,0 +1,20 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+module.exports = normalize;
+
+// Takes data from API messages and converts them to appropriate objects
+// (lowecase, correct values, etc.).
+function normalize(data) {
+ // convert `data.Timestamp` nanosecond value to a float in milliseconds.
+ var oneMillion = 1e6;
+ var timestamp = data.Timestamp / oneMillion;
+
+ return {
+ message: data.Message,
+ file: data.File,
+ stream: data.Stream,
+ timestamp: timestamp
+ };
+}
diff --git a/client/browser/components/log/render.js b/client/browser/components/log/render.js
new file mode 100644
index 0000000..ac0f253
--- /dev/null
+++ b/client/browser/components/log/render.js
@@ -0,0 +1,34 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var h = require('mercury').h;
+var moment = require('moment');
+
+module.exports = render;
+
+function render(state) {
+ var date = moment(state.timestamp).format('H:mm:ss.SSS');
+
+ var children = [
+ h('span.timestamp', date + ' '),
+ h('span.filename', state.file ? state.file + ': ' : '')
+ ];
+
+ // TODO(jasoncampbell): render in a pre tag instead
+
+ // A single trailing newline is always ignored.
+ // Ignoring the last character, check if there are any newlines in message.
+ if (state.message.slice(0, -1).indexOf('\n') !== -1) {
+ children.push('\u23ce'); // U+23CE RETURN SYMBOL
+ children.push('br');
+ }
+
+ var message = h('span.message', {
+ className: state.stream || 'unknown'
+ }, state.message);
+
+ children.push(message);
+
+ return h('.log', children);
+}
diff --git a/client/browser/components/results-console/follow-hook.js b/client/browser/components/results-console/follow-hook.js
new file mode 100644
index 0000000..2041535
--- /dev/null
+++ b/client/browser/components/results-console/follow-hook.js
@@ -0,0 +1,34 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+module.exports = FollowConsoleHook;
+
+// # FollowConsoleHook(state.follow)
+//
+// Used to hook into the vdom cycle so that DOM attributes can be accessed
+// and manipulated causing the DOM node to scroll to the bottom.
+function FollowConsoleHook(shouldFollow){
+ if (!(this instanceof FollowConsoleHook)) {
+ return new FollowConsoleHook(shouldFollow);
+ }
+
+ this.track = shouldFollow;
+}
+
+FollowConsoleHook.prototype.hook = function(node, property) {
+ if (! this.track) {
+ return;
+ }
+
+ // Do not mutate the DOM node until it has been inserted.
+ process.nextTick(update);
+
+ function update() {
+ var visibleHeight = node.clientHeight;
+ var overflow = node.scrollHeight - visibleHeight;
+
+ // Scroll the overflowing content into view.
+ node.scrollTop = overflow;
+ }
+};
diff --git a/client/browser/components/results-console/index.js b/client/browser/components/results-console/index.js
new file mode 100644
index 0000000..6368399
--- /dev/null
+++ b/client/browser/components/results-console/index.js
@@ -0,0 +1,36 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+var debug = require('debug')('components:results-console:state');
+
+module.exports = resultsConsole;
+module.exports.render = require('./render');
+
+function resultsConsole() {
+ debug('create');
+
+ var state = hg.state({
+ logs: hg.array([]),
+ open: hg.value(false),
+ follow: hg.value(true),
+ channels: {
+ follow: follow
+ }
+ });
+
+ return state;
+}
+
+function follow(state, data) {
+ var following = state.follow();
+
+ if (data.scrolling && following) {
+ state.follow.set(false);
+ }
+
+ if (data.scrolledToBottom) {
+ state.follow.set(true);
+ }
+}
diff --git a/client/browser/components/results-console/render.js b/client/browser/components/results-console/render.js
new file mode 100644
index 0000000..964ddfb
--- /dev/null
+++ b/client/browser/components/results-console/render.js
@@ -0,0 +1,23 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('components:results-console:render');
+var h = require('mercury').h;
+var scroll = require('../../event-handlers/scroll');
+var followHook = require('./follow-hook');
+var log = require('../log');
+
+module.exports = render;
+
+function render(state, channels) {
+ debug('update console %o', state);
+
+ return h('.console', {
+ className: state.open ? 'open' : 'closed',
+ 'ev-scroll': scroll(channels.follow, { scrolling: true }),
+ 'follow-console': followHook(state.follow)
+ }, [
+ h('.text', state.logs.map(log.render))
+ ]);
+}
diff --git a/client/browser/components/toast.js b/client/browser/components/toast.js
new file mode 100644
index 0000000..cdd2b4c
--- /dev/null
+++ b/client/browser/components/toast.js
@@ -0,0 +1,32 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+var h = require('mercury').h;
+
+module.exports = toast;
+module.exports.render = render;
+
+function toast(options) {
+ options = options || {};
+
+ var state = hg.state({
+ message: hg.value(options.message || ''),
+ active: hg.value(options.message ? true : false),
+ });
+
+ return state;
+}
+
+function render(state) {
+ var attributes = {
+ className: state.active ? 'active' : ''
+ };
+
+ var children = [
+ h('p', state.message)
+ ];
+
+ return h('.toast', attributes, children);
+}
diff --git a/client/browser/config.js b/client/browser/config.js
new file mode 100644
index 0000000..7405b36
--- /dev/null
+++ b/client/browser/config.js
@@ -0,0 +1,47 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var window = require('global/window');
+
+module.exports = window.config = config;
+
+var store = window.localStorage || new MemStorage();
+
+// # config(key, value)
+//
+// Quick method to set configuration values via the developer console. For
+// instance to point at a different API url:
+//
+// config('api-url', 'http://120.0.0.1:9999')
+//
+function config(key, value) {
+ if (typeof value === 'undefined') {
+ return get(key);
+ } else {
+ return set(key, value);
+ }
+}
+
+function get(key) {
+ return store.getItem(key);
+}
+
+function set(key, value) {
+ store.setItem(key, value);
+}
+
+// Stubbed out localStorage API for running tests.
+function MemStorage() {
+ this.store = {};
+}
+
+MemStorage.prototype.getItem = function(key) {
+ if (this.store.hasOwnProperty(key)) {
+ return this.store[key];
+ }
+};
+
+MemStorage.prototype.setItem = function(key, value) {
+ this.store[key] = value;
+};
diff --git a/client/browser/event-handlers/ace-change.js b/client/browser/event-handlers/ace-change.js
new file mode 100644
index 0000000..dd0d9f0
--- /dev/null
+++ b/client/browser/event-handlers/ace-change.js
@@ -0,0 +1,27 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+var extend = require('xtend');
+
+// Tell the singleton Delegator to listen to the custom 'ace-change' event.
+hg.Delegator().listenTo('ace-change');
+
+module.exports = hg.BaseEvent(change);
+
+// # change(channel, data)
+//
+// Custom change event for the Ace editor which broadcasts updated content of
+// an editor on change.
+function change(event, broadcast) {
+ // The ProxyEvent object in dom-delegator doesn't set event.detail for
+ // custom events until my PR is merged and a new version is attached to
+ // mercury.
+ //
+ // TODO(jasoncampbell): Send a path upstream
+ var detail = event.detail || event._rawEvent.detail;
+ var data = extend(this.data, detail);
+
+ broadcast(data);
+}
diff --git a/client/browser/event-handlers/scroll.js b/client/browser/event-handlers/scroll.js
new file mode 100644
index 0000000..f92d514
--- /dev/null
+++ b/client/browser/event-handlers/scroll.js
@@ -0,0 +1,33 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+var extend = require('xtend');
+
+hg.Delegator().listenTo('scroll');
+
+module.exports = hg.BaseEvent(scroll);
+
+// # scroll(channel, data)
+//
+// dom-delegator event for scrolling. Currently broadcasts:
+//
+// { scrolledToBottom: true || false }
+//
+// Example:
+//
+// h('.scroller', { 'ev-scroll': scroll(channel) })
+//
+function scroll(event, broadcast) {
+ var element = event.target;
+ var overflowTop = element.scrollTop;
+ var visibleHeight = element.clientHeight;
+ var scrolledToBottom = overflowTop + visibleHeight >= element.scrollHeight;
+
+ var data = extend(this.data, {
+ scrolledToBottom: scrolledToBottom
+ });
+
+ broadcast(data);
+}
diff --git a/client/browser/index.js b/client/browser/index.js
index 2c0bda1..e89d822 100644
--- a/client/browser/index.js
+++ b/client/browser/index.js
@@ -2,39 +2,88 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
-var _ = require('lodash');
-var path = require('path');
-var superagent = require('superagent');
+var debug = require('debug')('app');
+var domready = require('domready');
+var window = require('global/window');
+var document = require('global/document');
+var hg = require('mercury');
+var render = require('./render');
+var router = require('./router');
+var bundle = require('./components/bundle');
+var bundles = require('./components/bundles');
+var toast = require('./components/toast');
+var api = require('./api');
-var Playground = require('./playground');
+// Make the debug module accessible via the console. This allows the module's
+// output to be toggled by opening the developer console and typing:
+//
+// debug.enable('*')
+//
+// It is also possible to only enable specific, namespaced statements. For
+// example to only see comments from components.
+//
+// debug.enable('component:*')
+//
+// SEE: https://www.npmjs.com/package/debug
+window.debug = require('debug');
-_.forEach(document.querySelectorAll('.playground'), function(el) {
- var src = el.getAttribute('data-src');
- console.log('Creating playground', src);
+domready(function domisready() {
+ debug('DOM is ready, initializing mercury app.');
- fetchBundle(src, function(err, bundle) {
- if (err) {
- el.innerHTML = '<div class="error"><p>Playground error.' +
- '<br>Bundle not found: <strong>' + src + '</strong></p></div>';
- return;
- }
- new Playground(el, src, bundle); // jshint ignore:line
+ var state = hg.state({
+ bundles: bundles(),
+ uuid: hg.value(''),
+ toast: toast(),
+ title: hg.value('Vanadium Playground')
});
-});
-function fetchBundle(loc, cb) {
- var basePath = '/bundles';
- console.log('Fetching bundle', loc);
- superagent
- .get(path.join(basePath, loc))
- .accept('json')
- .end(function(err, res) {
+ router({
+ '/#!/': index,
+ '/#!/:uuid': show
+ }).on('notfound', notfound);
+
+ hg.app(document.body, state, render);
+
+ // Route: "/#!/" - The homepage, show list of available bundles.
+ function index(params, route) {
+ // Unset state.uuid from previous route.
+ state.uuid.set('');
+
+ api.bundles(function(err, list) {
if (err) {
- return cb(err);
+ return console.error('TODO: API list error', err);
}
- if (res.error) {
- return cb(res.error);
+
+ var length = list.length;
+
+ for (var i = 0; i < length; i++) {
+ var data = list[i];
+ state.bundles.put(data.uuid, bundle(data));
}
- cb(null, res.body);
});
-}
+ }
+
+ // Route: "/#!/:uuid" - Show a single bundle
+ function show(params) {
+ state.uuid.set(params.uuid);
+
+ // TODO(jasoncampbell): If there is not an entry for `params.uuid` show a
+ // spinner/loader.
+ //
+ // SEE: https://github.com/veyron/release-issues/issues/1890
+
+ api.get(params.uuid, function(err, data) {
+ if (err) {
+ return console.error('TODO: API GET error', err);
+ }
+
+ // Set and or update bundle
+ state.bundles.put(data.uuid, bundle(data));
+ });
+ }
+
+ // SEE: https://github.com/veyron/release-issues/issues/1890
+ function notfound(href) {
+ console.error('TODO: not found error - %s', href);
+ }
+});
diff --git a/client/browser/playground.js b/client/browser/playground.js
deleted file mode 100644
index d8f509e..0000000
--- a/client/browser/playground.js
+++ /dev/null
@@ -1,614 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-module.exports = Playground;
-
-var _ = require('lodash');
-var http = require('http');
-var moment = require('moment');
-var path = require('path');
-var superagent = require('superagent');
-var url = require('url');
-
-var m = require('mercury');
-var h = m.h;
-
-var Editor = require('./widgets/editor');
-var Spinner = require('./widgets/spinner');
-
-// Timeout for save and load requests, in milliseconds.
-var storageRequestTimeout = 1000;
-
-// Shows each file in a tab.
-// * el: The DOM element to mount on.
-// * name: Name of this playground instance, used in debug messages.
-// * bundle: The default bundle formatted as {files:[{name, body}]}, as written
-// by pgbundle, to use if id is unspecified or if load fails.
-function Playground(el, name, bundle) {
- this.name_ = name;
- this.defaultBundle_ = bundle;
- this.files_ = [];
- this.editors_ = [];
- this.editorSpinner_ = new Spinner();
- // scrollState_ changes should not trigger render_, thus are not monitored
- // by mercury.
- this.scrollState_ = {bottom: true};
- // Mercury framework state. Changes to state trigger virtual DOM render.
- var state = m.state({
- notification: m.value({}),
- // False on page load (no bundle), empty if default bundle is loaded.
- bundleId: m.value(false),
- // Incrementing counter on each bundle reload to force render.
- bundleVersion: m.value(0),
- activeTab: m.value(0),
- idToLoad: m.value(''),
- // Save or load request in progress.
- requestActive: m.value(false),
- nextRunId: m.value(0),
- running: m.value(false),
- hasRun: m.value(false),
- consoleEvents: m.value([]),
- // Mercury framework channels. References to event handlers callable from
- // rendering code.
- channels: {
- switchTab: this.switchTab.bind(this),
- setIdToLoad: this.setIdToLoad.bind(this),
- // For testing. Load in production can be done purely using links.
- // Reload is not necessary before adding history.
- load: this.load.bind(this),
- save: this.save.bind(this),
- run: this.run.bind(this),
- stop: this.stop.bind(this),
- reset: this.reset.bind(this)
- }
- });
- m.app(el, state, this.render_.bind(this));
- // When page is first loaded, load bundle in url.
- this.loadUrl_(state, window.location.href);
- // When user goes forward/back, load bundle in url.
- var that = this;
- window.addEventListener('popstate', function(ev) {
- console.log('window.onpopstate', ev);
- that.loadUrl_(state, window.location.href);
- });
- // Enable ev-scroll listening.
- m.Delegator().listenTo('scroll');
-}
-
-// Attempts to load the bundle with id specified in the url, or the default
-// bundle if id is not specified.
-Playground.prototype.loadUrl_ = function(state, pgurl) {
- this.setNotification_(state);
- this.url_ = url.parse(pgurl, true);
- // Deleted to make url.format() use this.url_.query.
- delete this.url_.search;
- var bId = this.url_.query.id || '';
- // Filled into idToLoad to allow retrying using Load button.
- state.idToLoad.set(bId);
- if (bId) {
- console.log('Loading bundle', bId, 'from URL');
- process.nextTick(this.load.bind(this, state, {id: bId}));
- } else {
- console.log('Loading default bundle');
- process.nextTick(
- this.setBundle_.bind(this, state, this.defaultBundle_, ''));
- }
-};
-
-// Builds bundle object from editor contents.
-Playground.prototype.getBundle_ = function() {
- var editors = this.editors_;
- return {
- files: _.map(this.files_, function(file, i) {
- var editor = editors[i];
- return {
- name: file.name,
- body: editor.getText()
- };
- })
- };
-};
-
-// Loads bundle object into editor contents, updates url.
-Playground.prototype.setBundle_ = function(state, bundle, id) {
- this.files_ = _.map(bundle.files, function(file) {
- return _.assign({}, file, {
- basename: path.basename(file.name),
- type: path.extname(file.name).substr(1)
- });
- });
- this.editors_ = _.map(this.files_, function(file) {
- return new Editor(file.type, file.body);
- });
- state.activeTab.set(0);
- this.resetState_(state);
- state.bundleId.set(id);
- // Increment counter in state to force render.
- state.bundleVersion.set((state.bundleVersion() + 1) & 0x7fffffff);
- this.setUrlForId_(id);
-};
-
-// Updates url with new id if different from the current one.
-Playground.prototype.setUrlForId_ = function(id) {
- if (!id && !this.url_.query.id || id === this.url_.query.id) {
- return;
- }
- if (!id) {
- delete this.url_.query.id;
- } else {
- this.url_.query.id = id;
- }
- window.history.pushState(null, '', url.format(this.url_));
-};
-
-// Determines base url for backend calls.
-Playground.prototype.getBackendUrl_ = function() {
- var pgaddr = this.url_.query.pgaddr;
- if (pgaddr) {
- if (window.location.hostname === 'localhost') {
- console.log('Using pgaddr', pgaddr);
- return pgaddr;
- } else {
- console.error('pgaddr disabled on non-localhost clients');
- pgaddr = '';
- }
- }
- var origin = window.location.protocol + '//' + window.location.host;
- var defaddr = origin + '/api';
- console.log('Using default backend', defaddr);
- return defaddr;
-};
-
-// Shows notification, green if success is set, red otherwise.
-// Call with undefined/blank msg to clear notification.
-Playground.prototype.setNotification_ = function(state, msg, success) {
- if (!msg) {
- state.notification.set({});
- } else {
- state.notification.set({message: msg, ok: success});
- // TODO(ivanpi): Expire message.
- }
-};
-
-// Renders button with provided label and target.
-// If disable is true or a request is active, the button is disabled.
-Playground.prototype.button_ = function(state, label, target, disable) {
- if (disable || state.requestActive) {
- return h('button.btn', {
- disabled: true
- }, label);
- } else {
- return h('button.btn', {
- 'ev-click': target
- }, label);
- }
-};
-
-Playground.prototype.renderLoadBar_ = function(state) {
- var idToLoad = h('input.bundleid', {
- type: 'text',
- name: 'idToLoad',
- size: 64,
- maxLength: 64,
- value: state.idToLoad,
- 'ev-input': m.sendChange(state.channels.setIdToLoad)
- });
- var loadBtn = this.button_(state, 'Load',
- m.sendClick(state.channels.load, {id: state.idToLoad}),
- state.running);
-
- return h('div.widget-bar', [
- h('span', [idToLoad]),
- h('span.btns', [loadBtn])
- ]);
-};
-
-Playground.prototype.renderResetBar_ = function(state) {
- var idShow = h('span.bundleid',
- state.bundleId || (state.bundleId === '' ? '<default>' : '<none>'));
- var link = h('a', {
- href: window.location.href
- }, 'link');
- var notif = h('span.notif.' + (state.notification.ok ? 'success' : 'error'),
- state.notification.message || '');
-
- var resetBtn = this.button_(state, 'Reset',
- m.sendClick(state.channels.reset),
- state.bundleId === false);
- var reloadBtn = this.button_(state, 'Reload',
- m.sendClick(state.channels.load, {id: state.bundleId}),
- state.running || !state.bundleId);
-
- return h('div.widget-bar', [
- h('span', [idShow, ' ', link, ' ', notif]),
- h('span.btns', [resetBtn, reloadBtn])
- ]);
-};
-
-Playground.prototype.renderTabBar_ = function(state) {
- var tabs = _.map(this.files_, function(file, i) {
- var selector = 'div.tab';
- if (i === state.activeTab) {
- selector += '.active';
- }
- return h(selector, {
- 'ev-click': m.sendClick(state.channels.switchTab, {index: i})
- }, file.basename);
- });
-
- var runStopBtn = state.running ?
- this.button_(state, 'Stop', m.sendClick(state.channels.stop)) :
- this.button_(state, 'Run', m.sendClick(state.channels.run),
- state.bundleId === false);
- var saveBtn = this.button_(state, 'Save',
- m.sendClick(state.channels.save),
- state.running || (state.bundleId === false));
-
- return h('div.widget-bar', [
- h('span', tabs),
- h('span.btns', [runStopBtn, saveBtn])
- ]);
-};
-
-Playground.prototype.renderEditors_ = function(state) {
- var editors = _.map(this.editors_, function(editor, i) {
- var properties = {};
- if (i !== state.activeTab) {
- // Use "visibility: hidden" rather than "display: none" because the latter
- // causes the editor to initialize lazily and thus flicker when it's first
- // opened.
- properties['style'] = {visibility: 'hidden'};
- }
- return h('div.editor', properties, editor);
- });
-
- if (state.requestActive) {
- editors.push(this.editorSpinner_);
- }
-
- return h('div.editors', editors);
-};
-
-Playground.prototype.renderConsoleEvent_ = function(event) {
- var children = [];
- if (event.Timestamp) {
- // Convert UTC to local time.
- var t = moment(event.Timestamp / 1e6);
- children.push(h('span.timestamp', t.format('H:mm:ss.SSS') + ' '));
- }
- if (event.File) {
- children.push(h('span.filename', path.basename(event.File) + ': '));
- }
- // A single trailing newline is always ignored.
- // Ignoring the last character, check if there are any newlines in message.
- if (event.Message.slice(0, -1).indexOf('\n') !== -1) {
- // Multiline messages are marked with U+23CE and started in a new line.
- children.push('\u23ce'/* U+23CE RETURN SYMBOL */, h('br'));
- }
- children.push(h('span.message.' + (event.Stream || 'unknown'),
- event.Message));
- return h('div', children);
-};
-
-// ScrollHandle provides a hook to keep the console scrolled to the bottom
-// unless the user has scrolled up, and the update method to detect the
-// user scrolling up.
-function ScrollHandle(scrollState) {
- this.scrollState_ = scrollState;
- this.enableScrollUpdates_ = false;
-}
-
-ScrollHandle.prototype.hook = function(elem, propname) {
- var that = this;
- process.nextTick(function() {
- if (that.scrollState_.bottom) {
- elem.scrollTop = elem.scrollHeight - elem.clientHeight;
- }
- that.enableScrollUpdates_ = true;
- });
-};
-
-ScrollHandle.prototype.update = function(ev) {
- var el = ev.currentTarget;
- if (this.enableScrollUpdates_) {
- // scrollHeight and clientHeight are rounded to an integer, so we need to
- // compare fuzzily.
- this.scrollState_.bottom =
- Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) <= 2.01;
- }
-};
-
-Playground.prototype.renderConsole_ = function(state) {
- if (state.hasRun) {
- var scrollHandle = new ScrollHandle(this.scrollState_);
- return h('div.console.open', {
- 'ev-scroll': scrollHandle.update.bind(scrollHandle),
- 'scrollhook': scrollHandle
- }, [
- h('div.text', _.map(state.consoleEvents, this.renderConsoleEvent_))
- ]);
- }
- return h('div.console');
-};
-
-Playground.prototype.render_ = function(state) {
- return h('div.pg', [
- this.renderLoadBar_(state),
- this.renderResetBar_(state),
- this.renderTabBar_(state),
- this.renderEditors_(state),
- this.renderConsole_(state)
- ]);
-};
-
-// Switches active tab to data.index.
-Playground.prototype.switchTab = function(state, data) {
- this.setNotification_(state);
- state.activeTab.set(data.index);
-};
-
-// Reads the idToLoad text box into state.
-Playground.prototype.setIdToLoad = function(state, formdata) {
- this.setNotification_(state);
- state.idToLoad.set(formdata.idToLoad);
-};
-
-Playground.prototype.showMessage_ = function(state, prefix, msg, ok) {
- var fullMsg = prefix + ': ' + msg;
- if (ok) {
- console.log(fullMsg);
- } else {
- console.error(fullMsg);
- }
- this.setNotification_(state, fullMsg, ok);
-};
-
-// Returns callback to be used for save and load requests. Callback loads the
-// bundle returned from the server and updates bundleId and url.
-Playground.prototype.saveLoadCallback_ = function(state, operation) {
- var that = this;
- return function(rerr, res) {
- state.requestActive.set(false);
- var processResponse = function() {
- if (rerr) {
- if (rerr.timeout) {
- return 'request timed out';
- }
- return 'error connecting to server: ' + rerr;
- }
- if (res.body && res.body.Error) {
- // TODO(ivanpi): Special handling of 404? Retry on other errors?
- return 'error ' + res.status + ': ' + res.body.Error;
- }
- if (res.error) {
- return 'error ' + res.status + ': unknown';
- }
- if (!res.body.Link || !res.body.Data) {
- return 'invalid response format';
- }
- var bundle;
- try {
- bundle = JSON.parse(res.body.Data);
- } catch (jerr) {
- return 'error parsing Data: ' + res.body.Data + '\n' + jerr.message;
- }
- // Opens bundle in editors, updates bundleId and url.
- that.setBundle_(state, bundle, res.body.Link);
- return null;
- };
- var errm = processResponse();
- if (!errm) {
- state.idToLoad.set('');
- that.showMessage_(state, operation, 'success', true);
- } else {
- // Load/save failed.
- if (state.bundleId() === false) {
- // If no bundle was loaded, load default.
- that.setBundle_(state, that.defaultBundle_, '');
- } else {
- // Otherwise, reset url to previously loaded bundle.
- that.setUrlForId_(state.bundleId());
- }
- that.showMessage_(state, operation, errm);
- }
- };
-};
-
-// Loads bundle for data.id.
-Playground.prototype.load = function(state, data) {
- this.setNotification_(state);
- if (!data.id) {
- this.showMessage_(state, 'load', 'cannot load blank id');
- return;
- }
- superagent
- .get(this.getBackendUrl_() + '/load?id=' + encodeURIComponent(data.id))
- .accept('json')
- .timeout(storageRequestTimeout)
- .end(this.saveLoadCallback_(state, 'load ' + data.id));
- state.requestActive.set(true);
-};
-
-// Saves bundle and updates bundleId with the received id.
-Playground.prototype.save = function(state) {
- this.setNotification_(state);
- superagent
- .post(this.getBackendUrl_() + '/save')
- .type('json')
- .accept('json')
- .timeout(storageRequestTimeout)
- .send(this.getBundle_())
- .end(this.saveLoadCallback_(state, 'save'));
- state.requestActive.set(true);
-};
-
-// Sends the files to the compile backend, streaming the response into the
-// console.
-Playground.prototype.run = function(state) {
- if (state.running()) {
- console.log('Already running', this.name_);
- return;
- }
- var runId = state.nextRunId();
-
- this.setNotification_(state);
- state.running.set(true);
- state.hasRun.set(true);
- state.consoleEvents.set([{Message: 'Running...'}]);
- this.scrollState_.bottom = true;
-
- var compileUrl = this.getBackendUrl_() + '/compile';
- if (this.url_.query.debug === '1') {
- compileUrl += '?debug=1';
- }
-
- var reqData = this.getBundle_();
-
- // TODO(sadovsky): To deal with cached responses, shift timestamps (based on
- // current time) and introduce a fake delay. Also, switch to streaming
- // messages, for usability.
- var that = this;
-
- // If the user stops the current run or resets the playground, functions
- // wrapped with ifRunActive become no-ops.
- var ifRunActive = function(cb) {
- return function() {
- if (runId === state.nextRunId()) {
- cb.apply(this, arguments);
- }
- };
- };
-
- var appendToConsole = function(events) {
- state.consoleEvents.set(state.consoleEvents().concat(events));
- };
- var makeEvent = function(stream, message) {
- return {Stream: stream, Message: message};
- };
-
- var urlp = url.parse(compileUrl);
-
- var options = {
- method: 'POST',
- protocol: urlp.protocol,
- hostname: urlp.hostname,
- port: urlp.port || (urlp.protocol === 'https:' ? '443' : '80'),
- path: urlp.path,
- // TODO(ivanpi): Change once deployed.
- withCredentials: false,
- headers: {
- 'accept': 'application/json',
- 'content-type': 'application/json'
- }
- };
-
- var req = http.request(options);
-
- var watchdog = null;
- // The heartbeat function clears the existing timeout (if any) and, if the run
- // is still active, starts a new timeout.
- var heartbeat = function() {
- if (watchdog !== null) {
- clearTimeout(watchdog);
- }
- watchdog = null;
- ifRunActive(function() {
- // TODO(ivanpi): Reduce timeout duration when server heartbeat is added.
- watchdog = setTimeout(function() {
- process.nextTick(ifRunActive(function() {
- req.destroy();
- appendToConsole(makeEvent('syserr', 'Server response timed out.'));
- }));
- }, 10500);
- })();
- };
-
- var endRunIfActive = ifRunActive(function() {
- that.stop(state);
- // Cleanup watchdog timer.
- heartbeat();
- });
-
- // error and close callbacks call endRunIfActive in the next tick to ensure
- // that if both events are triggered, both are executed before the run is
- // ended by either.
- req.on('error', ifRunActive(function(err) {
- console.error('Connection error: ' + err.message + '\n' + err.stack);
- appendToConsole(makeEvent('syserr', 'Error connecting to server.'));
- process.nextTick(endRunIfActive);
- }));
-
- // Holds partial prefix of next response line.
- var partialLine = '';
-
- req.on('response', ifRunActive(function(res) {
- heartbeat();
- if (res.statusCode !== 0 && res.statusCode !== 200) {
- appendToConsole(makeEvent('syserr', 'HTTP status ' + res.statusCode));
- }
- res.on('data', ifRunActive(function(chunk) {
- heartbeat();
- // Each complete line is one JSON Event.
- var eventsJson = (partialLine + chunk).split('\n');
- partialLine = eventsJson.pop();
- var events = [];
- _.forEach(eventsJson, function(line) {
- // Ignore empty lines.
- line = line.trim();
- if (line) {
- var ev;
- try {
- ev = JSON.parse(line);
- } catch (err) {
- console.error('Error parsing line: ' + line + '\n' + err.message);
- events.push(makeEvent('syserr', 'Error parsing server response.'));
- endRunIfActive();
- return false;
- }
- events.push(ev);
- }
- });
- appendToConsole(events);
- }));
- }));
-
- req.on('close', ifRunActive(function() {
- // Sanity check: partialLine should be empty when connection is closed.
- partialLine = partialLine.trim();
- if (partialLine) {
- console.error('Connection closed without newline after: ' + partialLine);
- appendToConsole(makeEvent('syserr', 'Error parsing server response.'));
- }
- process.nextTick(endRunIfActive);
- }));
-
- req.write(JSON.stringify(reqData));
- req.end();
-
- // Start watchdog.
- heartbeat();
-};
-
-// Clears the console and resets all editors to their original contents.
-Playground.prototype.reset = function(state) {
- this.resetState_(state);
- _.forEach(this.editors_, function(editor) {
- editor.reset();
- });
- this.setUrlForId_(state.bundleId());
-};
-
-Playground.prototype.resetState_ = function(state) {
- state.consoleEvents.set([]);
- this.scrollState_.bottom = true;
- this.stop(state);
- state.hasRun.set(false);
-};
-
-// Stops bundle execution.
-Playground.prototype.stop = function(state) {
- this.setNotification_(state);
- state.nextRunId.set((state.nextRunId() + 1) & 0x7fffffff);
- state.running.set(false);
-};
diff --git a/client/browser/render.js b/client/browser/render.js
new file mode 100644
index 0000000..580af26
--- /dev/null
+++ b/client/browser/render.js
@@ -0,0 +1,52 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+var h = require('mercury').h;
+var debug = require('debug')('render');
+var header = require('./components/header');
+var bundles = require('./components/bundles');
+var bundle = require('./components/bundle');
+
+module.exports = render;
+
+// # render(state)
+//
+// High-level render function for entire playground app state.
+function render(state) {
+ return h('.playground', [
+ hg.partial(header.render, state, state.channels),
+ hg.partial(main, state)
+ ]);
+}
+
+function main(state, anchor) {
+ debug('update %o', state);
+
+ // Possible scenarios to be considered:
+ //
+ // * loading/initial state
+ // * a list of bundles
+ // * a single bundle
+ // * an error
+ //
+ // Currently there are only two screens to show:
+ //
+ // 1. A list of bundles
+ // 2. A single bundle
+ var partial;
+
+ // If there is a uuid show a single bundle
+ if (state.uuid) {
+ partial = hg.partial(bundle.render,
+ state.bundles[state.uuid],
+ state.channels
+ );
+ } else {
+ // By default show a list of bundles
+ partial = hg.partial(bundles.render, state.bundles);
+ }
+
+ return h('main', [ partial ]);
+}
diff --git a/client/browser/router/anchor.js b/client/browser/router/anchor.js
new file mode 100644
index 0000000..df2170c
--- /dev/null
+++ b/client/browser/router/anchor.js
@@ -0,0 +1,47 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+var h = require('mercury').h;
+var options = { preventDefault: true };
+var href = require('./index').href;
+var hashbang = require('./hashbang');
+
+// Create a handle for dom-delegation. This step treats the generated `handle`
+// the same as a mercury channel.
+var handle = hg.Delegator.allocateHandle(click);
+
+module.exports = anchor;
+
+// # anchor(attributes, text)
+//
+// Helper for creating virtual-dom anchors that trigger route changes. All
+// event/DOM delegation and router coupling are handled in this function
+// so that any anchor tags can be simply created with:
+//
+// h('p', [
+// anchor({ href: '/some-url' }, 'Click me!');
+// ]);
+//
+// Clicking the generated link will fire the callbacks in the router and have
+// the application state update appropriately.
+function anchor(attributes, text) {
+ // Ensure that the href has the hashbang boilerplate, this makes ctrl+click
+ // open the right url for loading the app in the correct state.
+ attributes.href = hashbang(attributes.href);
+
+ attributes['ev-click'] = hg.sendClick(handle, {
+ href: attributes.href
+ }, options);
+
+ return h('a', attributes, text);
+}
+
+// # click(data)
+//
+// Used as a mercury channel to update the current route using the exported
+// `router.href` observable.
+function click(data) {
+ href.set(data.href);
+}
diff --git a/client/browser/router/hashbang.js b/client/browser/router/hashbang.js
new file mode 100644
index 0000000..0206c5f
--- /dev/null
+++ b/client/browser/router/hashbang.js
@@ -0,0 +1,30 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+module.exports = hashbang;
+
+// # hashbang(href)
+//
+// Convert an href to a hashbang url.
+//
+// var href = hashbang('/foo')
+// //=> href === '/#!/foo'
+//
+// Returns a "/#!" prefixed href.
+function hashbang(string) {
+ string = string || '';
+
+ // trim leading slash
+ var href = string.replace(/^\//, '');
+
+ if (! href.match(/^\#\!/)) {
+ // add the hashbang
+ href = '#!/' + href;
+ }
+
+ // add leading slash
+ href = '/' + href;
+
+ return href;
+}
diff --git a/client/browser/router/index.js b/client/browser/router/index.js
new file mode 100644
index 0000000..d39e80c
--- /dev/null
+++ b/client/browser/router/index.js
@@ -0,0 +1,119 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('router');
+var EE = require('events').EventEmitter;
+var inherits = require('util').inherits;
+var spa = require('single-page');
+var routes = require('routes');
+var hg = require('mercury');
+var hashbang = require('./hashbang');
+var window = require('global/window');
+
+// The block of code here creates an observable for the current href
+//
+// NOTE: The check for `window.location` allows tests to run in node. Since
+// this code is executing when `require(...)` is called on this module it will
+// be executed at startup, the check prevents errors with accessing undefined
+// properties on `window.location`.
+var pathname = window.location ? window.location.pathname : '';
+var current = hashbang(pathname);
+var atom = hg.value(current);
+
+module.exports = Router;
+module.exports.href = atom;
+
+// The single-page module does not natively support hashbang urls (only
+// window.location.pathname) so there are some work arounds to make sure
+// everything works as expected:
+//
+// * `window.addEventListener('hashchange', router);` - For back button support.
+// * String routes are prefixed with "/#!"
+// * The single-page callback, `router.update(href)` takes special care to
+// consider the hashbang prefix.
+
+// The routes module provides a simple way to define patterns that match to
+// functions.
+//
+// SEE: https://www.npmjs.com/package/routes
+function Router(hash) {
+ if (!(this instanceof Router)) {
+ return new Router(hash);
+ }
+
+ var router = this;
+
+ router.routes = routes();
+
+ for (var key in hash) { // jshint ignore: line
+ router.routes.addRoute(key, hash[key]);
+ }
+
+ router.sp = spa(router.update.bind(this));
+
+ // SEE: Router.prototype.handleEvent
+ window.addEventListener('hashchange', router);
+
+ atom(function onhref(href) {
+ debug('atom update: %s', href);
+ href = hashbang(href);
+ router.sp.show(href);
+ });
+
+ EE.call(router);
+}
+
+inherits(Router, EE);
+
+// # router.update(href)
+//
+// Fired anytime the href is updated (via single-page). This method handles
+// href normalizing and route matching based on the hash of functions passed
+// in on initialization.
+Router.prototype.update = function(href) {
+ debug('dom href update: %s', href);
+
+ var router = this;
+ var hash = window.location.hash;
+
+ // force initial hashbang in case href is missing it.
+ if (href === '/' && ! href.match(hash)) {
+ debug('hash mismatch: %s', hash);
+ href += hash;
+ } else if (href === '/') {
+ // At this point it's possible that the `href` is missing the /#!/ and the
+ // original `window.location.hash` is empty. In this case update the
+ // `href` so that it will match '/#!/' and use `router.sp.push` to update
+ // the url without triggering any callbacks.
+ href = hashbang(href);
+ router.sp.push(href);
+ }
+
+ var route = router.routes.match(href);
+
+ if (route) {
+ route.fn.call(router, route.params, route);
+ } else {
+ router.emit('notfound', href);
+ }
+};
+
+// # router.handleEvent(event)
+//
+// Implments the `EventListener` API so that the "hashchange" event can be
+// listened to in order to enable the back button with hashbang urls.
+//
+// SEE: https://mdn.io/EventListener
+// SEE: https://mdn.io/WindowEventHandlers/onhashchange
+Router.prototype.handleEvent = function(event) {
+ if (event.type !== 'hashchange') {
+ return;
+ }
+
+ var router = this;
+ var hash = String(window.location.hash);
+ var current = hashbang(hash);
+
+ router.update(current);
+};
diff --git a/client/browser/util.js b/client/browser/util.js
new file mode 100644
index 0000000..ad7e7e1
--- /dev/null
+++ b/client/browser/util.js
@@ -0,0 +1,22 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+
+module.exports = {
+ toArray: toArray
+};
+
+// # toArray(obejct)
+//
+// Convert an object to an array.
+function toArray(object) {
+ var keys = Object.keys(object);
+ var array = keys.map(toItem);
+
+ return array;
+
+ function toItem(key, index) {
+ return object[key];
+ }
+}
diff --git a/client/browser/widgets/ace-widget.js b/client/browser/widgets/ace-widget.js
new file mode 100644
index 0000000..9559fcd
--- /dev/null
+++ b/client/browser/widgets/ace-widget.js
@@ -0,0 +1,111 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('widgets:editor');
+var path = require('path');
+var prr = require('prr');
+var window = require('global/window');
+var document = require('global/document');
+
+module.exports = AceWidget;
+
+// NOTE(sadovsky): We considered both Ace and CodeMirror, but CodeMirror has
+// weird issues, e.g. its injected editor divs can obscure other DOM elements.
+
+// Mercury widget that wraps a code editor around a file object.
+function AceWidget(file) {
+ if (!(this instanceof AceWidget)) {
+ return new AceWidget(file);
+ }
+
+ var editor = this;
+
+ // This tells Mercury to treat AceWidget as a mercury/virtual-dom widget not
+ // to render it's internals.
+ //
+ // There will be trouble if the editor.type is ever changed so it is
+ // protected here using prr.
+ prr(editor, 'type', 'Widget');
+
+ editor.filename = file.name;
+ editor.extname = path.extname(file.name).replace('.', '');
+ editor.text = file.body;
+
+ debug('initialization %s - %s', editor.filename, editor.visible);
+}
+
+// The first time an instance of widget is seen it will have widget.init()
+// called by mercury. It is expected to return a dom element.
+AceWidget.prototype.init = function() {
+ var editor = this;
+
+ debug('init() - %s', editor.filename);
+
+ // Ace does all kinds of weird stuff with the global objects (window,
+ // document) so moving the module's initialization here makes it possible to
+ // run the tests headlessly in node without throwing errors.
+ var ace = require('brace');
+ require('brace/mode/javascript');
+ require('brace/mode/golang');
+ require('brace/theme/monokai');
+
+ var element = document.createElement('div');
+
+ editor.ace = ace.edit(element);
+ editor.ace.setTheme('ace/theme/monokai');
+ editor.ace.on('change', function(data) {
+ var event = new window.CustomEvent('ace-change', {
+ detail: {
+ name: editor.filename,
+ body: editor.ace.getValue()
+ }
+ });
+
+ // Events are processed synchronously and there is no guarantee that the
+ // element or it's parent will be in the DOM at the time this event
+ // fires. The `process.nextTick` here ensures that the custom event above
+ // is not emitted until after the virtual-dom create, update, insert cycle
+ // has finished and the custom event above has a chance to bubble.
+ process.nextTick(function dispatch(){
+ element.dispatchEvent(event);
+ });
+ });
+
+
+ var session = editor.ace.getSession();
+
+ switch (editor.extname) {
+ case 'go':
+ session.setMode('ace/mode/golang');
+ break;
+ case 'vdl':
+ session.setMode('ace/mode/golang');
+ break;
+ case 'json':
+ session.setMode('ace/mode/javascript');
+ break;
+ case 'js':
+ session.setMode('ace/mode/javascript');
+ break;
+ default:
+ throw new Error('Language type not supported: ' + editor.extname);
+ }
+
+ // Tell Ace about the file contents.
+ session.setValue(editor.text);
+
+ // Disable syntax checking. The UI is annoying and only works for JS.
+ session.setOption('useWorker', false);
+
+ // Ensure that update is called on the first vdom cycle.
+ editor.update(null, element);
+
+ return element;
+};
+
+AceWidget.prototype.update = function(prev, element) {
+ var current = this;
+
+ current.ace = current.ace || prev.ace;
+};
diff --git a/client/browser/widgets/editor.js b/client/browser/widgets/editor.js
deleted file mode 100644
index 58ea6a4..0000000
--- a/client/browser/widgets/editor.js
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-module.exports = Editor;
-
-// NOTE(sadovsky): We considered both Ace and CodeMirror, but CodeMirror appears
-// to be incompatible with Bootstrap and has other weird issues, e.g. its
-// injected editor divs can obscure other DOM elements.
-
-var ace = require('brace');
-require('brace/mode/javascript');
-require('brace/mode/golang');
-require('brace/theme/monokai');
-
-// Mercury widget that wraps a code editor.
-// * type: Type of code. Currently either 'js' or 'go'.
-// * text: Initial code.
-function Editor(type, text) {
- this.type_ = type;
- this.text_ = text;
- // Every explicitly created (not copied) editor has a unique nonce.
- this.nonce_ = Editor.nonceSeed_;
- Editor.nonceSeed_ = (Editor.nonceSeed_ + 1) & 0x7fffffff;
- this.aceEditor_ = null;
-}
-
-Editor.nonceSeed_ = 0;
-
-// This tells Mercury to treat Editor as a widget and not try to render its
-// internals.
-Editor.prototype.type = 'Widget';
-
-Editor.prototype.init = function() {
- console.log('EditorWidget.init');
- var el = document.createElement('div');
- this.mount(el, this.type_, this.text_);
- return el;
-};
-
-Editor.prototype.update = function(prev, el) {
- console.log('EditorWidget.update');
- // If update is called with the currently mounted Editor instance or its
- // copy (detected by nonce), remount would reset any edits. Remount should
- // only happen if a new editor instance (with a new nonce) is explicitly
- // created.
- if (this.nonce_ !== prev.nonce_) {
- this.mount(el, this.type_, this.text_);
- }
-};
-
-Editor.prototype.getText = function() {
- return this.aceEditor_.getValue();
-};
-
-Editor.prototype.reset = function() {
- // The '-1' argument puts the cursor at the document start.
- this.aceEditor_.setValue(this.text_, -1);
-};
-
-// Creates a new Ace editor instance and mounts it on a DOM node.
-// * el: The DOM node to mount on.
-// * type: Type of code. Currently either 'js' or 'go'.
-// * text: Initial code.
-Editor.prototype.mount = function(el, type, text) {
- var editor = this.aceEditor_ = ace.edit(el);
- editor.setTheme('ace/theme/monokai');
-
- var session = editor.getSession();
- switch (type) {
- case 'go':
- session.setMode('ace/mode/golang');
- break;
- case 'vdl':
- session.setMode('ace/mode/golang');
- break;
- case 'json':
- session.setMode('ace/mode/javascript');
- break;
- case 'js':
- session.setMode('ace/mode/javascript');
- break;
- default:
- throw new Error('Language type not supported: ' + type);
- }
-
- session.setValue(text);
-
- // Disable syntax checking. The UI is annoying and only works for JS anyways.
- session.setOption('useWorker', false);
-};
diff --git a/client/browser/widgets/spinner.js b/client/browser/widgets/spinner.js
deleted file mode 100644
index 6aa03fa..0000000
--- a/client/browser/widgets/spinner.js
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-module.exports = Spinner;
-
-var spinjs = require('spin.js');
-
-// Mercury widget for displaying a spinner over an element.
-function Spinner() {
- this.spinner_ = new spinjs({
- className: 'spinner-internal',
- 'z-index': 2000000000,
- color: '#ffffff'
- });
-}
-
-// This tells Mercury to treat Spinner as a widget and not try to render its
-// internals.
-Spinner.prototype.type = 'Widget';
-
-Spinner.prototype.init = function() {
- console.log('SpinnerWidget.init');
- var el = document.createElement('div');
- el.setAttribute('class', 'spinner-overlay');
- this.spinner_.spin(el);
- return el;
-};
-
-Spinner.prototype.update = function(prev, el) {
- console.log('SpinnerWidget.update');
-};
diff --git a/client/package.json b/client/package.json
index 362cf7f..1188b23 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,32 +1,46 @@
{
+ "private": true,
"name": "vanadium-pg-client",
"description": "Vanadium playground web client",
"version": "0.0.0",
"bugs": {
"url": "https://github.com/veyron/release-issues/issues"
},
+ "main": "browser/index.js",
"dependencies": {
"brace": "^0.4.0",
"browserify": "^8.1.1",
- "lodash": "^3.0.0",
- "mercury": "^12.0.0",
- "minifyify": "^6.1.0",
- "moment": "^2.9.0",
+ "debug": "^2.1.3",
+ "domready": "^1.0.7",
+ "format": "^0.2.1",
+ "global": "^4.3.0",
+ "hyperquest": "^1.0.1",
+ "mercury": "^14.0.0",
+ "moment": "^2.10.2",
"pgbundle": "0.0.1",
- "spin.js": "^2.0.2",
- "superagent": "^0.21.0"
+ "prr": "^1.0.1",
+ "routes": "^2.0.0",
+ "run-parallel": "^1.1.0",
+ "single-page": "^1.0.0",
+ "split": "^0.3.3",
+ "superagent": "^1.1.0",
+ "through2": "^0.6.3",
+ "xtend": "^4.0.0"
},
- "homepage": "https://vanadium.googlesource.com/release.projects.playground/+/master/client",
- "repository": {
- "type": "git",
- "url": "https://vanadium.googlesource.com/release.projects.playground"
- },
+ "homepage": "https://github.com/vanadium/playground",
"scripts": {
"start": "make start",
"test": "make test"
},
"devDependencies": {
"http-server": "^0.7.4",
- "jshint": "^2.6.0"
- }
+ "jshint": "^2.6.0",
+ "run-browser": "^2.0.2",
+ "tape": "^3.5.0"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git@github.com:vanadium/playground.git"
+ },
+ "license": "BSD"
}
diff --git a/client/public/index.html b/client/public/index.html
index ac3bad8..8fb013a 100644
--- a/client/public/index.html
+++ b/client/public/index.html
@@ -1,20 +1,10 @@
<!DOCTYPE html>
<html>
- <head>
- <title>Playground</title>
- </head>
- <body>
- <main>
- <h1>Hello, playground!</h1>
- <ul>
- <li><a href="./go">Go</a></li>
- <li><a href="./js">Javascript</a></li>
- </ul>
- <h3>More challenge?</h3>
- <ul>
- <li><a href="./go_js">Go client, Javascript server</a></li>
- <li><a href="./js_go">Javascript client, Go server</a></li>
- </ul>
- </main>
- </body>
+<head>
+ <title>Playground</title>
+ <link rel="stylesheet" href="/bundle.css">
+ <script src="/bundle.js" async></script>
+</head>
+<body>
+</body>
</html>
diff --git a/client/stylesheets/index.css b/client/stylesheets/index.css
index d1d628d..55b47ce 100644
--- a/client/stylesheets/index.css
+++ b/client/stylesheets/index.css
@@ -74,6 +74,11 @@
position: absolute; /* stack editors one on top of the other */
width: 100%;
height: 100%;
+ visibility: hidden;
+}
+
+.pg .editor.active {
+ visibility: visible;
}
.pg .ace_editor {
@@ -88,6 +93,7 @@
width: 100%;
height: 4px; /* matches .editors border-top; expands on run */
transition: height 0.2s;
+ overflow: hidden;
}
.pg .console .text {
diff --git a/client/test/index.js b/client/test/index.js
new file mode 100644
index 0000000..6cf839a
--- /dev/null
+++ b/client/test/index.js
@@ -0,0 +1,7 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+require('./test-api-url');
+require('./test-config');
+require('./test-component-log');
diff --git a/client/test/test-api-url.js b/client/test/test-api-url.js
new file mode 100644
index 0000000..a8fc916
--- /dev/null
+++ b/client/test/test-api-url.js
@@ -0,0 +1,89 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var test = require('tape');
+var API = require('../browser/api').constructor;
+var format = require('format');
+
+test('var api = API({ url: "https://host.tld/" })', function(t) {
+ var options = { url: 'https://random-host.tld/' };
+ var api = API(options);
+
+ t.test('api.url()', function(t) {
+ t.equal(api.url(), options.url);
+ t.end();
+ });
+
+ t.test('api.url({ uuid: <uuid>, action: "read" })', function(t) {
+ var options = {
+ uuid: '0E88DE8C-88E3-43CC-BD80-87207CC124FA',
+ action: 'read'
+ };
+ var expected = format('%sload?id=%s', api.url(), options.uuid);
+
+ t.equal(api.url(options), expected);
+ t.end();
+ });
+
+ t.test('api.url({ action: "create" })', function(t) {
+ var options = {
+ action: 'create'
+ };
+ var expected = format('%ssave', api.url());
+
+ t.equal(api.url(options), expected);
+ t.end();
+ });
+
+ t.test('api.url({ action: "run" })', function(t) {
+ var options = {
+ action: 'run'
+ };
+ var expected = format('%scompile', api.url());
+
+ t.equal(api.url(options), expected);
+ t.end();
+ });
+});
+
+test('var api = API({ url: "https://host.tld/api" })', function(t) {
+ var options = { url: 'https://random-host.tld/api' };
+ var api = API(options);
+
+ t.test('api.url()', function(t) {
+ t.equal(api.url(), options.url + '/');
+ t.end();
+ });
+
+ t.test('api.url({ uuid: <uuid>, action: "read" })', function(t) {
+ var options = {
+ uuid: '0E88DE8C-88E3-43CC-BD80-87207CC124FA',
+ action: 'read'
+ };
+ var expected = format('%sload?id=%s', api.url(), options.uuid);
+
+ t.equal(api.url(options), expected);
+ t.end();
+ });
+
+ t.test('api.url({ action: "create" })', function(t) {
+ var options = {
+ action: 'create'
+ };
+ var expected = format('%ssave', api.url());
+
+ t.equal(api.url(options), expected);
+ t.end();
+ });
+
+ t.test('api.url({ action: "run" })', function(t) {
+ var options = {
+ action: 'run'
+ };
+ var expected = format('%scompile', api.url());
+
+ t.equal(api.url(options), expected);
+ t.end();
+ });
+});
diff --git a/client/test/test-component-log.js b/client/test/test-component-log.js
new file mode 100644
index 0000000..3efadbb
--- /dev/null
+++ b/client/test/test-component-log.js
@@ -0,0 +1,25 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var test = require('tape');
+var log = require('../browser/components/log');
+
+test('log(data)', function(t) {
+ var data = {
+ File: '',
+ Message: 'Response finished↵',
+ Stream: 'debug',
+ Timestamp: 1428710996621822700
+ };
+ var state = log(data);
+ var value = state();
+
+ t.deepEqual(value, {
+ file: '',
+ message: data.Message,
+ stream: data.Stream,
+ timestamp: data.Timestamp / 1e6
+ });
+ t.end();
+});
diff --git a/client/test/test-config.js b/client/test/test-config.js
new file mode 100644
index 0000000..8493e9f
--- /dev/null
+++ b/client/test/test-config.js
@@ -0,0 +1,16 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var test = require('tape');
+var config = require('../browser/config');
+
+test('config(key, value)', function(t) {
+ var url = 'https://playground.staging.v.io/api';
+
+ config('api-url', url);
+ var value = config('api-url');
+
+ t.equal(value, url);
+ t.end();
+});