Merge client from www into release.go.playground/client.
diff --git a/client/content/playgrounds/code/fortune/ex0-go/src/client/client.go b/client/content/playgrounds/code/fortune/ex0-go/src/client/client.go
new file mode 100644
index 0000000..140e270
--- /dev/null
+++ b/client/content/playgrounds/code/fortune/ex0-go/src/client/client.go
@@ -0,0 +1,34 @@
+// index=3
+package main
+
+import (
+ "fmt"
+ "time"
+
+ _ "v.io/core/veyron/profiles"
+ "v.io/core/veyron2"
+
+ "fortune"
+)
+
+func main() {
+ // Initialize Vanadium.
+ ctx, shutdown := veyron2.Init()
+ defer shutdown()
+
+ // Create a new stub that binds to address without
+ // using the name service.
+ stub := fortune.FortuneClient("fortune")
+
+ // Issue a Get() RPC.
+ // We do this in a loop to give the server time to start up.
+ var fortune string
+ for {
+ var err error
+ if fortune, err = stub.Get(ctx); err == nil {
+ break
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ fmt.Println(fortune)
+}
diff --git a/client/content/playgrounds/code/fortune/ex0-go/src/fortune/fortune.vdl b/client/content/playgrounds/code/fortune/ex0-go/src/fortune/fortune.vdl
new file mode 100644
index 0000000..a823cab
--- /dev/null
+++ b/client/content/playgrounds/code/fortune/ex0-go/src/fortune/fortune.vdl
@@ -0,0 +1,10 @@
+// index=3
+package fortune
+
+type Fortune interface {
+ // Returns a random fortune.
+ Get() (Fortune string | error)
+
+ // Adds a fortune to the set used by Get().
+ Add(Fortune string) error
+}
diff --git a/client/content/playgrounds/code/fortune/ex0-go/src/server/server.go b/client/content/playgrounds/code/fortune/ex0-go/src/server/server.go
new file mode 100644
index 0000000..3b0966e
--- /dev/null
+++ b/client/content/playgrounds/code/fortune/ex0-go/src/server/server.go
@@ -0,0 +1,78 @@
+// index=1
+package main
+
+import (
+ "fmt"
+ "math/rand"
+
+ "v.io/core/veyron/lib/signals"
+ "v.io/core/veyron/profiles"
+ vflag "v.io/core/veyron/security/flag"
+ "v.io/core/veyron2"
+ "v.io/core/veyron2/ipc"
+
+ "fortune"
+)
+
+// The Fortuned implementation.
+type fortuned struct {
+ // The set of all fortunes.
+ fortunes []string
+
+ // Used to pick a random index in 'fortunes'.
+ random *rand.Rand
+}
+
+// Initialize server state.
+func newFortuned() *fortuned {
+ return &fortuned{
+ fortunes: []string{
+ "You will reach the height of success in whatever you do.",
+ "You have remarkable power which you are not using.",
+ "Everything will now come your way.",
+ },
+ random: rand.New(rand.NewSource(99)),
+ }
+}
+
+// Methods that get called by RPC requests.
+func (f *fortuned) Get(_ ipc.ServerContext) (Fortune string, err error) {
+ return f.fortunes[f.random.Intn(len(f.fortunes))], nil
+}
+
+func (f *fortuned) Add(_ ipc.ServerContext, Fortune string) error {
+ f.fortunes = append(f.fortunes, Fortune)
+ return nil
+}
+
+// Main - Set everything up.
+func main() {
+ // Initialize Vanadium.
+ ctx, shutdown := veyron2.Init()
+ defer shutdown()
+ log := veyron2.GetLogger(ctx)
+
+ // Create a new instance of the runtime's server functionality.
+ server, err := veyron2.NewServer(ctx)
+ if err != nil {
+ log.Panic("failure creating server: ", err)
+ }
+
+ // Create the fortune server stub.
+ fortuneServer := fortune.FortuneServer(newFortuned())
+
+ // Create an endpoint and begin listening.
+ if endpoint, err := server.Listen(profiles.LocalListenSpec); err == nil {
+ fmt.Printf("Listening at: %v\n", endpoint)
+ } else {
+ log.Panic("error listening to service: ", err)
+ }
+
+ // Start the fortune server at "fortune".
+ if err := server.Serve("fortune", fortuneServer, vflag.NewAuthorizerOrDie()); err != nil {
+ log.Panic("error serving service: ", err)
+ }
+
+ // Wait forever.
+ <-signals.ShutdownOnSignals(ctx)
+}
diff --git a/client/content/playgrounds/code/fortune/ex0-js/client.js b/client/content/playgrounds/code/fortune/ex0-js/client.js
new file mode 100644
index 0000000..7b61c14
--- /dev/null
+++ b/client/content/playgrounds/code/fortune/ex0-js/client.js
@@ -0,0 +1,41 @@
+var veyron = require('veyron');
+var context = veyron.context;
+
+/**
+ * Create a Vanadium runtime using the configuration defined in config.js,
+ * and bind it to the bakery/cookie/fortune service.
+ */
+veyron.init(function(err, rt){
+ if (err) { return error(err); }
+
+ var ctx = new context.Context();
+
+ retryBindTo(ctx, rt, function(err, fortuneService) {
+ if (err) { return error(err); }
+
+ fortuneService.getRandomFortune(ctx, function(err, fortune) {
+ if (err) { return error(err); }
+
+ console.log(fortune);
+ process.exit(0);
+ });
+ });
+});
+
+function retryBindTo(ctx, rt, cb) {
+ rt.bindTo(ctx, 'bakery/cookie/fortune', function(err, fortuneService) {
+ if (err) {
+ // Try again in 100ms
+ return setTimeout(function(){
+ retryBindTo(ctx, rt, cb);
+ }, 100);
+ }
+
+ cb(null, fortuneService);
+ });
+}
+
+function error(err) {
+ console.error(err);
+ process.exit(1);
+}
diff --git a/client/content/playgrounds/code/fortune/ex0-js/server.js b/client/content/playgrounds/code/fortune/ex0-js/server.js
new file mode 100644
index 0000000..f3ab264
--- /dev/null
+++ b/client/content/playgrounds/code/fortune/ex0-js/server.js
@@ -0,0 +1,61 @@
+var veyron = require('veyron');
+
+/**
+ * 1) Implement a simple fortune service
+ */
+
+var fortuneService = {
+ // List of fortunes
+ fortunes: [],
+
+ numFortunesServed: 0,
+
+ // Gets a random fortune
+ getRandomFortune: function(ctx) {
+ var numExistingfortunes = this.fortunes.length;
+ if(numExistingfortunes === 0) {
+ throw new Error('Sorry! No fortune available :(');
+ }
+ var randomIndex = Math.floor(Math.random() * numExistingfortunes);
+ var fortune = this.fortunes[randomIndex];
+ this.numFortunesServed++;
+ console.info('Serving:', fortune);
+ return fortune;
+ },
+
+ // Adds a new fortune
+ addNewFortune: function(ctx, fortune) {
+ if(!fortune || fortune.trim() === '') {
+ throw new Error('Sorry! Can\'t add empty or null fortune!');
+ }
+ console.info('Adding:', fortune);
+ this.fortunes.push(fortune);
+ }
+};
+
+/**
+ * 2) Publish the fortune service
+ */
+
+// Create a Vanadium runtime using the configuration
+veyron.init().then(function(rt){
+ // Serve the fortune server under a name. Serve returns a Promise object
+ rt.serve('bakery/cookie/fortune', fortuneService).then(function() {
+ console.log('Fortune server serving under: bakery/cookie/fortune \n');
+ }).catch(function(err) {
+ console.error('Failed to serve the fortune server because: \n', err);
+ process.exit(1);
+ });
+}).catch(function(err) {
+ console.error('Failed to start the fortune server because:', err);
+ process.exit(1);
+});
+
+// Let's add a few fortunes to start with
+[
+ 'The fortune you seek is in another cookie.',
+ 'Everything will now come your way.',
+ 'Conquer your fears or they will conquer you.'
+].forEach(function(fortune) {
+ fortuneService.addNewFortune(null, fortune);
+});
diff --git a/client/content/playgrounds/fortune.md b/client/content/playgrounds/fortune.md
new file mode 100644
index 0000000..1b13cd1
--- /dev/null
+++ b/client/content/playgrounds/fortune.md
@@ -0,0 +1,78 @@
+= yaml =
+title: Playground example - Fortune
+status: draft
+sort: 05
+= yaml =
+
+<span style="background-color:red">
+TODO(jregan): plan is to insert one or more of these as appropriate in tutorials
+</span>
+
+(Taken from: https://docs.google.com/a/google.com/document/d/189JXetSjHc980LuSl88Y7_VtG4tyZ0iggILEWD2_JKc/edit#)
+
+This is an example of a simple Fortune application in Vanadium. It has two
+parts, a client and a server. The client can either request a fortune from the
+server, or add a new fortune to the server.
+
+
+## Interface Definition
+
+The remote interface exposed by the server is defined in a .vdl file. Here is
+an example Fortune service:
+
+ package fortune
+
+ type Fortune interface {
+ // Returns a random fortune.
+ Get() (Fortune string | error)
+
+ // Adds a fortune to the set used by Get().
+ Add(Fortune string) error
+ }
+
+The services exposes two methods - `Get` which returns a random fortune string
+from a fortune repository held by the service, and `Add` that adds the provided
+fortune string to the repository.
+
+
+## Implementation
+
+### Server
+
+<div class="lang-go">
+The server implements the `Get` and `Add` methods defined in the vdl interface.
+
+Inside the `main()` function, we create a new Vanadium runtime with
+`r := rt.New()`, and use the runtime to create a new server with
+`r.NewServer()`.
+
+We use the `fortune` package generated from the vdl along with the fortuned
+implementation to create a new fortune server.
+
+The server listens for a tcp connection on localhost, and mounts itself on the
+mounttable with the name "fortune".
+</div>
+
+<!--
+<span class="lang-js">TODO(nlacasse): describe the js server</span>
+-->
+
+### Client
+
+<div class="lang-go">
+The client binds to the fortune server with a `.FortuneClient("fortune")` call,
+and then issues a `Get` request. We do the `.Get` request in a loop to give
+the server a chance to start up.
+</div>
+
+<!--
+<span class="lang-js">TODO(nlacasse): describe the js client</span>
+-->
+
+### Code
+
+<div class="lang-go playground" data-srcdir="/fortune/ex0-go"></div>
+
+<!--
+<div class="lang-js playground" data-srcdir="/fortune/ex0-js"></div>
+-->
diff --git a/client/playground/client/editor.js b/client/playground/client/editor.js
new file mode 100644
index 0000000..7918791
--- /dev/null
+++ b/client/playground/client/editor.js
@@ -0,0 +1,80 @@
+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;
+ this.aceEditor_ = null;
+}
+
+// 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() {
+ console.log('EditorWidget.update');
+};
+
+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');
+ // https://github.com/ajaxorg/ace/wiki/Configuring-Ace
+ editor.setOptions({
+ fontFamily: 'Source Code Pro',
+ fontSize: '13px'
+ });
+
+ 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/playground/client/embedded.js b/client/playground/client/embedded.js
new file mode 100644
index 0000000..168bdbd
--- /dev/null
+++ b/client/playground/client/embedded.js
@@ -0,0 +1,294 @@
+module.exports = EmbeddedPlayground;
+
+var _ = require('lodash');
+var http = require('http');
+var mercury = require('mercury');
+var moment = require('moment');
+var path = require('path');
+var url = require('url');
+
+var Editor = require('./editor');
+
+var m = mercury;
+var h = mercury.h;
+
+// Shows each file in a tab.
+// * el: The DOM element to mount on.
+// * id: Identifier for this playground instance, used in debug messages.
+// * files: List of {name, body}, as written by bundler.
+function EmbeddedPlayground(el, id, files) {
+ this.id_ = id;
+ this.files_ = _.map(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);
+ });
+ this.state_ = m.struct({
+ activeTab: m.value(0),
+ nextRunId: m.value(0),
+ running: m.value(false),
+ hasRun: m.value(false),
+ consoleEvents: m.value([])
+ });
+ mercury.app(el, this.state_, this.render_.bind(this));
+}
+
+EmbeddedPlayground.prototype.renderTopBar_ = function(state) {
+ var that = this;
+
+ var tabs = _.map(this.files_, function(file, i) {
+ var selector = 'div.tab';
+ if (i === state.activeTab) {
+ selector += '.active';
+ }
+ return h(selector, {
+ 'ev-click': function() {
+ that.state_.activeTab.set(i);
+ }
+ }, file.basename);
+ });
+
+ var runBtn = h('button.btn', {
+ 'ev-click': that.run.bind(that)
+ }, 'Run');
+ var resetBtn = h('button.btn', {
+ 'ev-click': that.reset.bind(that)
+ }, 'Reset');
+
+ return h('div.top-bar', [h('div', tabs), h('div.btns', [runBtn, resetBtn])]);
+};
+
+EmbeddedPlayground.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);
+ });
+
+ return h('div.editors', editors);
+};
+
+function renderConsoleEvent(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);
+}
+
+EmbeddedPlayground.prototype.renderConsole_ = function(state) {
+ if (state.hasRun) {
+ return h('div.console.open', [
+ h('div.text', _.map(state.consoleEvents, renderConsoleEvent))
+ ]);
+ }
+ return h('div.console');
+};
+
+EmbeddedPlayground.prototype.render_ = function(state) {
+ return h('div.pg', [
+ this.renderTopBar_(state),
+ this.renderEditors_(state),
+ this.renderConsole_(state)
+ ]);
+};
+
+// Sends the files to the backend, then injects the response in the console.
+EmbeddedPlayground.prototype.run = function() {
+ if (this.state_.running()) {
+ console.log('Already running', this.id_);
+ return;
+ }
+ var runId = this.state_.nextRunId();
+
+ // TODO(sadovsky): Visually disable the "Run" button or change it to a "Stop"
+ // button.
+ this.state_.running.set(true);
+ this.state_.hasRun.set(true);
+ this.state_.consoleEvents.set([{Message: 'Running...'}]);
+
+ var myUrl = url.parse(window.location.href, true);
+ var pgaddr = myUrl.query.pgaddr;
+ if (pgaddr) {
+ console.log('Using pgaddr', pgaddr);
+ } else {
+ pgaddr = 'https://staging.v.io/playground';
+ }
+ var compileUrl = pgaddr + '/compile';
+ if (myUrl.query.debug === '1') {
+ compileUrl += '?debug=1';
+ }
+
+ var editors = this.editors_;
+ var reqData = {
+ files: _.map(this.files_, function(file, i) {
+ var editor = editors[i];
+ return {
+ Name: file.name,
+ Body: editor.getText()
+ };
+ }),
+ Identities: []
+ };
+
+ // 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, state = this.state_;
+
+ // 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.endRun_();
+ // 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.
+EmbeddedPlayground.prototype.reset = function() {
+ this.state_.consoleEvents.set([]);
+ _.forEach(this.editors_, function(editor) {
+ editor.reset();
+ });
+ this.endRun_();
+ this.state_.hasRun.set(false);
+};
+
+EmbeddedPlayground.prototype.endRun_ = function() {
+ this.state_.nextRunId.set(this.state_.nextRunId() + 1);
+ this.state_.running.set(false);
+};
diff --git a/client/playground/client/index.js b/client/playground/client/index.js
new file mode 100644
index 0000000..3a7562f
--- /dev/null
+++ b/client/playground/client/index.js
@@ -0,0 +1,36 @@
+var _ = require('lodash');
+var path = require('path');
+var request = require('superagent');
+
+var EmbeddedPlayground = require('./embedded');
+
+_.forEach(document.querySelectorAll('.playground'), function(el) {
+ var srcdir = el.getAttribute('data-srcdir');
+ console.log('Creating playground', srcdir);
+
+ fetchBundle(srcdir, function(err, bundle) {
+ if (err) {
+ el.innerHTML = '<div class="error"><p>Playground error.' +
+ '<br>Bundle not found: <strong>' + srcdir + '</strong></p></div>';
+ return;
+ }
+ new EmbeddedPlayground(el, srcdir, bundle.files); // jshint ignore:line
+ });
+});
+
+function fetchBundle(loc, cb) {
+ var basePath = '/playgrounds/code/';
+ console.log('Fetching bundle', loc);
+ request
+ .get(path.join(basePath, loc, 'bundle.json'))
+ .accept('json')
+ .end(function(err, res) {
+ if (err) {
+ return cb(err);
+ }
+ if (res.error) {
+ return cb(res.error);
+ }
+ cb(null, res.body);
+ });
+}
diff --git a/client/stylesheets/playground.css b/client/stylesheets/playground.css
new file mode 100644
index 0000000..eacc394
--- /dev/null
+++ b/client/stylesheets/playground.css
@@ -0,0 +1,132 @@
+/* CSS rules for elements inside EmbeddedPlayground instances. */
+
+.pg {
+ width: 100%;
+}
+
+.pg .top-bar {
+ position: relative;
+}
+
+.pg .btn,
+.pg .tab {
+ cursor: pointer;
+ display: inline-block;
+ font-size: 14px;
+ font-weight: normal;
+ padding: 6px 12px;
+ user-select: none;
+}
+
+.pg .tab {
+ background-color: #bbb;
+ margin-right: 2px;
+}
+
+.pg .tab.active {
+ background-color: #ddd;
+}
+
+.pg .btns {
+ position: absolute;
+ top: 0;
+ right: 0;
+}
+
+.pg .btn {
+ background-color: #0277bd;
+ color: #fff;
+ border: 0;
+ margin-left: 2px;
+}
+
+/* TODO(sadovsky): Add .no-touch once we integrate Modernizr. */
+.pg .btn:hover {
+ background-color: #01579b;
+}
+
+.pg .btn:focus,
+.pg .btn:active {
+ outline: 0;
+}
+
+.pg .btn:active {
+ -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+ box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+}
+
+/* TODO(sadovsky): Make this reactive. */
+.pg .editors {
+ position: relative;
+ border-top: 4px solid #ddd;
+ height: 200px;
+}
+
+.pg .editor {
+ position: absolute; /* stack editors one on top of the other */
+ width: 100%;
+ height: 100%;
+}
+
+.pg .ace_editor {
+ width: 100%;
+ height: 100%;
+}
+
+.pg .console {
+ background-color: #ddd;
+ width: 100%;
+ height: 4px; /* matches .editors border-top; expands on run */
+ transition: height 0.2s;
+}
+
+.pg .console .text {
+ color: #222;
+ font-family: "Source Code Pro", monospace;
+ font-size: 13px;
+ overflow-y: auto;
+ padding: 8px;
+}
+
+.pg .console .text .timestamp {
+ color: #9e9e9e;
+}
+
+.pg .console .text .filename {
+ font-weight: bold;
+}
+
+/* Known bug: Chrome doesn't show tabs; refer to
+ https://code.google.com/p/chromium/issues/detail?id=398274 */
+.pg .console .text .message {
+ white-space: pre-wrap;
+ tab-size: 4;
+}
+
+.pg .console .text .stderr {
+ color: #d01716;
+}
+
+.pg .console .text .svc-stderr {
+ color: #d01716;
+}
+
+.pg .console .text .debug {
+ color: #455ede;
+}
+
+.pg .console .text .syserr {
+ color: #d01716;
+ font-weight: bold;
+}
+
+.pg .console.open {
+ height: 200px;
+ overflow: auto;
+}
+
+.clearfix {
+ display: table;
+ clear: both;
+ content: "";
+}
diff --git a/client/test/playground_test.sh b/client/test/playground_test.sh
new file mode 100755
index 0000000..33aebe9
--- /dev/null
+++ b/client/test/playground_test.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+
+# Tests that all embedded playgrounds execute successfully.
+
+# To debug playground compile errors you can build examples locally, e.g.:
+# $ cd content/playgrounds/code/fortune/ex0-go/src
+# $ GOPATH=$(dirname $(pwd)) VDLPATH=$(dirname $(pwd)) v23 go install ./...
+
+source "$(go list -f {{.Dir}} v.io/core/shell/lib)/shell_test.sh"
+source "$(go list -f {{.Dir}} v.io/playground)/lib/pg_test_util.sh"
+
+main() {
+ local -r WWWDIR="$(pwd)"
+ cd "${shell_test_WORK_DIR}"
+
+ setup_environment
+
+ build_go_binaries
+ install_vanadium_js
+ install_pgbundle
+
+ local -r EXAMPLE_DIRS=$(find "${WWWDIR}/content/playgrounds/code" -maxdepth 2 -mindepth 2)
+ for d in $EXAMPLE_DIRS; do
+ echo -e "\n\n>>>>> Test ${d}\n\n"
+ test_pg_example "${d}" "-v=false" || shell_test::fail "${d}: failed to run"
+ # TODO(sadovsky): Make this "clean exit" check more robust.
+ grep -q "\"Exited cleanly.\"" builder.out || shell_test::fail "${d}: did not exit cleanly"
+ rm -f builder.out
+ done
+
+ shell_test::pass
+}
+
+main "$@"