Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/components/crate-sidebar.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,22 @@
{{/if}}
{{/unless}}
</div>

{{#if this.playgroundLink}}
<div>
<a
href={{this.playgroundLink}}
target="_blank"
rel="noopener noreferrer"
local-class="playground-button"
data-test-playground-button
>
Try on Rust Playground
</a>
<p local-class="playground-help" data-test-playground-help>
The top 100 crates are available on the Rust Playground for you to
try out directly in your browser.
</p>
</div>
{{/if}}
</section>
30 changes: 30 additions & 0 deletions app/components/crate-sidebar.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { computed } from '@ember/object';
import { gt, readOnly } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import Component from '@glimmer/component';

import * as Sentry from '@sentry/browser';
import { didCancel } from 'ember-concurrency';

import { simplifyUrl } from './crate-sidebar/link';

const NUM_VERSIONS = 5;

export default class DownloadGraph extends Component {
@service playground;

@readOnly('args.crate.versions') sortedVersions;

@computed('sortedVersions')
Expand All @@ -24,4 +30,28 @@ export default class DownloadGraph extends Component {
get tomlSnippet() {
return `${this.args.crate.name} = "${this.args.version.num}"`;
}

get playgroundLink() {
let playgroundCrates = this.playground.crates;
if (!playgroundCrates) return;

let playgroundCrate = playgroundCrates.find(it => it.name === this.args.crate.name);
if (!playgroundCrate) return;

return `https://play.rust-lang.org/?code=use%20${playgroundCrate.id}%3B%0A%0Afn%20main()%20%7B%0A%20%20%20%20%2F%2F%20try%20using%20the%20%60${playgroundCrate.id}%60%20crate%20here%0A%7D`;
}

constructor() {
super(...arguments);

// load Rust Playground crates list, if necessary
if (!this.playground.crates) {
this.playground.loadCratesTask.perform().catch(error => {
if (!(didCancel(error) || error.isServerError || error.isNetworkError)) {
// report unexpected errors to Sentry
Sentry.captureException(error);
}
});
}
}
}
14 changes: 14 additions & 0 deletions app/components/crate-sidebar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,17 @@ ul.owners {
.reverse-deps-link {
composes: small from '../styles/shared/typography.module.css';
}

.playground-button {
composes: yellow-button small from '../styles/shared/buttons.module.css';
justify-content: center;
width: 220px;
margin-top: 20px;
}

.playground-help {
composes: small from '../styles/shared/typography.module.css';
max-width: 220px;
text-align: justify;
line-height: 1.3em;
}
13 changes: 13 additions & 0 deletions app/routes/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';

import * as Sentry from '@sentry/browser';
import { rawTimeout, task } from 'ember-concurrency';

export default class ApplicationRoute extends Route {
@service progress;
@service router;
@service session;
@service playground;

beforeModel() {
this.router.on('routeDidChange', () => {
Expand All @@ -24,10 +26,21 @@ export default class ApplicationRoute extends Route {
//
// eslint-disable-next-line ember-concurrency/no-perform-without-catch
this.session.loadUserTask.perform();

// trigger the preload task, but don't wait for the task to finish.
this.preloadPlaygroundCratesTask.perform().catch(() => {
// ignore all errors since we're only preloading here
});
}

@action loading(transition) {
this.progress.handle(transition);
return true;
}

@task(function* () {
yield rawTimeout(1000);
yield this.playground.loadCratesTask.perform();
})
preloadPlaygroundCratesTask;
}
16 changes: 16 additions & 0 deletions app/services/playground.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { alias } from '@ember/object/computed';
import Service from '@ember/service';

import { task } from 'ember-concurrency';

import ajax from '../utils/ajax';

export default class PlaygroundService extends Service {
@alias('loadCratesTask.lastSuccessful.value') crates;

@(task(function* () {
let response = yield ajax('https://play.rust-lang.org/meta/crates');
return response.crates;
}).drop())
loadCratesTask;
}
20 changes: 20 additions & 0 deletions app/utils/ajax.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ export class AjaxError extends Error {
this.cause = cause;
}

get isJsonError() {
return this.cause instanceof SyntaxError;
}

get isNetworkError() {
return this.cause instanceof TypeError;
}

get isHttpError() {
return this.cause instanceof HttpError;
}

get isServerError() {
return this.isHttpError && this.cause.response.status >= 500 && this.cause.response.status < 600;
}

get isClientError() {
return this.isHttpError && this.cause.response.status >= 400 && this.cause.response.status < 500;
}

async json() {
try {
return await this.cause.response.json();
Expand Down
2 changes: 1 addition & 1 deletion config/nginx.conf.erb
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ http {
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";

add_header Content-Security-Policy "default-src 'self'; connect-src 'self' *.ingest.sentry.io https://docs.rs https://<%= s3_host(ENV) %>; script-src 'self' 'unsafe-eval' 'sha256-n1+BB7Ckjcal1Pr7QNBh/dKRTtBQsIytFodRiIosXdE='; style-src 'self' 'unsafe-inline' https://code.cdn.mozilla.net; font-src https://code.cdn.mozilla.net; img-src *; object-src 'none'";
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' *.ingest.sentry.io https://docs.rs https://play.rust-lang.org https://<%= s3_host(ENV) %>; script-src 'self' 'unsafe-eval' 'sha256-n1+BB7Ckjcal1Pr7QNBh/dKRTtBQsIytFodRiIosXdE='; style-src 'self' 'unsafe-inline' https://code.cdn.mozilla.net; font-src https://code.cdn.mozilla.net; img-src *; object-src 'none'";
add_header Access-Control-Allow-Origin "*";

add_header Strict-Transport-Security "max-age=31536000" always;
Expand Down
93 changes: 93 additions & 0 deletions tests/components/crate-sidebar/playground-button-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { render, settled, waitFor } from '@ember/test-helpers';
import { module, test } from 'qunit';

import { defer } from 'rsvp';

import { hbs } from 'ember-cli-htmlbars';

import { setupRenderingTest } from 'cargo/tests/helpers';

import setupMirage from '../../helpers/setup-mirage';

module('Component | CrateSidebar | Playground Button', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);

hooks.beforeEach(function () {
let crates = [
{ name: 'addr2line', version: '0.14.1', id: 'addr2line' },
{ name: 'adler', version: '0.2.3', id: 'adler' },
{ name: 'adler32', version: '1.2.0', id: 'adler32' },
{ name: 'ahash', version: '0.4.7', id: 'ahash' },
{ name: 'aho-corasick', version: '0.7.15', id: 'aho_corasick' },
{ name: 'ansi_term', version: '0.12.1', id: 'ansi_term' },
{ name: 'ansi_term', version: '0.11.0', id: 'ansi_term_0_11_0' },
];

this.server.get('https://play.rust-lang.org/meta/crates', { crates });
});

test('button is hidden for unavailable crates', async function (assert) {
let crate = this.server.create('crate', { name: 'foo' });
this.server.create('version', { crate, num: '1.0.0' });

let store = this.owner.lookup('service:store');
this.crate = await store.findRecord('crate', crate.name);
this.version = (await this.crate.versions).firstObject;

await render(hbs`<CrateSidebar @crate={{this.crate}} @version={{this.version}} />`);
assert.dom('[data-test-playground-button]').doesNotExist();
assert.dom('[data-test-playground-help]').doesNotExist();
});

test('button is visible for available crates', async function (assert) {
let crate = this.server.create('crate', { name: 'aho-corasick' });
this.server.create('version', { crate, num: '1.0.0' });

let store = this.owner.lookup('service:store');
this.crate = await store.findRecord('crate', crate.name);
this.version = (await this.crate.versions).firstObject;

let expectedHref =
'https://play.rust-lang.org/?code=use%20aho_corasick%3B%0A%0Afn%20main()%20%7B%0A%20%20%20%20%2F%2F%20try%20using%20the%20%60aho_corasick%60%20crate%20here%0A%7D';

await render(hbs`<CrateSidebar @crate={{this.crate}} @version={{this.version}} />`);
assert.dom('[data-test-playground-button]').hasAttribute('href', expectedHref);
assert.dom('[data-test-playground-help]').exists();
});

test('button is hidden while Playground request is pending', async function (assert) {
let crate = this.server.create('crate', { name: 'aho-corasick' });
this.server.create('version', { crate, num: '1.0.0' });

let deferred = defer();
this.server.get('https://play.rust-lang.org/meta/crates', deferred.promise);

let store = this.owner.lookup('service:store');
this.crate = await store.findRecord('crate', crate.name);
this.version = (await this.crate.versions).firstObject;

render(hbs`<CrateSidebar @crate={{this.crate}} @version={{this.version}} />`);
await waitFor('[data-test-owners]');
assert.dom('[data-test-playground-button]').doesNotExist();
assert.dom('[data-test-playground-help]').doesNotExist();

deferred.resolve({ crates: [] });
await settled();
});

test('button is hidden if the Playground request fails', async function (assert) {
let crate = this.server.create('crate', { name: 'aho-corasick' });
this.server.create('version', { crate, num: '1.0.0' });

this.server.get('https://play.rust-lang.org/meta/crates', {}, 500);

let store = this.owner.lookup('service:store');
this.crate = await store.findRecord('crate', crate.name);
this.version = (await this.crate.versions).firstObject;

await render(hbs`<CrateSidebar @crate={{this.crate}} @version={{this.version}} />`);
assert.dom('[data-test-playground-button]').doesNotExist();
assert.dom('[data-test-playground-help]').doesNotExist();
});
});
38 changes: 38 additions & 0 deletions tests/services/playground-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { module, test } from 'qunit';

import { setupMirage } from 'ember-cli-mirage/test-support';

import { setupTest } from 'cargo/tests/helpers';

module('Service | Playground', function (hooks) {
setupTest(hooks);
setupMirage(hooks);

hooks.beforeEach(function () {
this.playground = this.owner.lookup('service:playground');
});

test('`crates` are available if the request succeeds', async function (assert) {
let crates = [
{ name: 'addr2line', version: '0.14.1', id: 'addr2line' },
{ name: 'adler', version: '0.2.3', id: 'adler' },
{ name: 'adler32', version: '1.2.0', id: 'adler32' },
{ name: 'ahash', version: '0.4.7', id: 'ahash' },
{ name: 'aho-corasick', version: '0.7.15', id: 'aho_corasick' },
{ name: 'ansi_term', version: '0.12.1', id: 'ansi_term' },
{ name: 'ansi_term', version: '0.11.0', id: 'ansi_term_0_11_0' },
];

this.server.get('https://play.rust-lang.org/meta/crates', { crates }, 200);

await this.playground.loadCratesTask.perform();
assert.deepEqual(this.playground.crates, crates);
});

test('loadCratesTask fails on HTTP error', async function (assert) {
this.server.get('https://play.rust-lang.org/meta/crates', {}, 500);

await assert.rejects(this.playground.loadCratesTask.perform());
assert.strictEqual(this.playground.crates, undefined);
});
});
47 changes: 46 additions & 1 deletion tests/utils/ajax-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ module('ajax()', function (hooks) {
assert.deepEqual(response, { foo: 'bar' });
});

test('throws an `HttpError` for non-2xx responses', async function (assert) {
test('throws an `HttpError` for 5xx responses', async function (assert) {
this.server.get('/foo', { foo: 42 }, 500);

await assert.rejects(ajax('/foo'), function (error) {
Expand All @@ -37,6 +37,11 @@ module('ajax()', function (hooks) {
assert.equal(error.method, 'GET');
assert.equal(error.url, '/foo');
assert.ok(error.cause);
assert.true(error.isHttpError);
assert.true(error.isServerError);
assert.false(error.isClientError);
assert.false(error.isJsonError);
assert.false(error.isNetworkError);

let { cause } = error;
assert.ok(cause instanceof HttpError);
Expand All @@ -50,6 +55,36 @@ module('ajax()', function (hooks) {
});
});

test('throws an `HttpError` for 4xx responses', async function (assert) {
this.server.get('/foo', { foo: 42 }, 404);

await assert.rejects(ajax('/foo'), function (error) {
let expectedMessage = 'GET /foo failed\n\ncaused by: HttpError: GET /foo failed with: 404 Not Found';

assert.ok(error instanceof AjaxError);
assert.equal(error.name, 'AjaxError');
assert.ok(error.message.startsWith(expectedMessage), error.message);
assert.equal(error.method, 'GET');
assert.equal(error.url, '/foo');
assert.ok(error.cause);
assert.true(error.isHttpError);
assert.false(error.isServerError);
assert.true(error.isClientError);
assert.false(error.isJsonError);
assert.false(error.isNetworkError);

let { cause } = error;
assert.ok(cause instanceof HttpError);
assert.equal(cause.name, 'HttpError');
assert.equal(cause.message, 'GET /foo failed with: 404 Not Found');
assert.equal(cause.method, 'GET');
assert.equal(cause.url, '/foo');
assert.ok(cause.response);
assert.equal(cause.response.url, '/foo');
return true;
});
});

test('throws an error for invalid JSON responses', async function (assert) {
this.server.get('/foo', () => '{ foo: 42');

Expand All @@ -62,6 +97,11 @@ module('ajax()', function (hooks) {
assert.equal(error.method, 'GET');
assert.equal(error.url, '/foo');
assert.ok(error.cause);
assert.false(error.isHttpError);
assert.false(error.isServerError);
assert.false(error.isClientError);
assert.true(error.isJsonError);
assert.false(error.isNetworkError);

let { cause } = error;
assert.ok(!(cause instanceof HttpError));
Expand All @@ -85,6 +125,11 @@ module('ajax()', function (hooks) {
assert.equal(error.method, 'GET');
assert.equal(error.url, '/foo');
assert.ok(error.cause);
assert.false(error.isHttpError);
assert.false(error.isServerError);
assert.false(error.isClientError);
assert.false(error.isJsonError);
assert.true(error.isNetworkError);

let { cause } = error;
assert.ok(!(cause instanceof HttpError));
Expand Down