Skip to content

Commit 90a28a2

Browse files
committed
Auto merge of #3211 - Turbo87:playground-button, r=pichfl
Show "Try on Rust Playground" button for crates that are available there <img width="989" alt="Bildschirmfoto 2021-01-28 um 18 06 47" src="https://user-images.githubusercontent.com/141300/106173514-4d029100-6194-11eb-8c15-0e05aba9c81f.png"> r? `@pichfl`
2 parents f9cb55e + 454ed8e commit 90a28a2

File tree

10 files changed

+289
-2
lines changed

10 files changed

+289
-2
lines changed

app/components/crate-sidebar.hbs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,22 @@
117117
{{/if}}
118118
{{/unless}}
119119
</div>
120+
121+
{{#if this.playgroundLink}}
122+
<div>
123+
<a
124+
href={{this.playgroundLink}}
125+
target="_blank"
126+
rel="noopener noreferrer"
127+
local-class="playground-button"
128+
data-test-playground-button
129+
>
130+
Try on Rust Playground
131+
</a>
132+
<p local-class="playground-help" data-test-playground-help>
133+
The top 100 crates are available on the Rust Playground for you to
134+
try out directly in your browser.
135+
</p>
136+
</div>
137+
{{/if}}
120138
</section>

app/components/crate-sidebar.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import { computed } from '@ember/object';
22
import { gt, readOnly } from '@ember/object/computed';
3+
import { inject as service } from '@ember/service';
34
import Component from '@glimmer/component';
45

6+
import * as Sentry from '@sentry/browser';
7+
import { didCancel } from 'ember-concurrency';
8+
59
const NUM_VERSIONS = 5;
610

711
export default class DownloadGraph extends Component {
12+
@service playground;
13+
814
@readOnly('args.crate.versions') sortedVersions;
915

1016
@computed('sortedVersions')
@@ -17,4 +23,28 @@ export default class DownloadGraph extends Component {
1723
get tomlSnippet() {
1824
return `${this.args.crate.name} = "${this.args.version.num}"`;
1925
}
26+
27+
get playgroundLink() {
28+
let playgroundCrates = this.playground.crates;
29+
if (!playgroundCrates) return;
30+
31+
let playgroundCrate = playgroundCrates.find(it => it.name === this.args.crate.name);
32+
if (!playgroundCrate) return;
33+
34+
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`;
35+
}
36+
37+
constructor() {
38+
super(...arguments);
39+
40+
// load Rust Playground crates list, if necessary
41+
if (!this.playground.crates) {
42+
this.playground.loadCratesTask.perform().catch(error => {
43+
if (!(didCancel(error) || error.isServerError || error.isNetworkError)) {
44+
// report unexpected errors to Sentry
45+
Sentry.captureException(error);
46+
}
47+
});
48+
}
49+
}
2050
}

app/components/crate-sidebar.module.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,17 @@ ul.owners {
119119
.reverse-deps-link {
120120
composes: small from '../styles/shared/typography.module.css';
121121
}
122+
123+
.playground-button {
124+
composes: yellow-button small from '../styles/shared/buttons.module.css';
125+
justify-content: center;
126+
width: 220px;
127+
margin-top: 20px;
128+
}
129+
130+
.playground-help {
131+
composes: small from '../styles/shared/typography.module.css';
132+
max-width: 220px;
133+
text-align: justify;
134+
line-height: 1.3em;
135+
}

app/routes/application.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import Route from '@ember/routing/route';
33
import { inject as service } from '@ember/service';
44

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

78
export default class ApplicationRoute extends Route {
89
@service progress;
910
@service router;
1011
@service session;
12+
@service playground;
1113

1214
beforeModel() {
1315
this.router.on('routeDidChange', () => {
@@ -24,10 +26,21 @@ export default class ApplicationRoute extends Route {
2426
//
2527
// eslint-disable-next-line ember-concurrency/no-perform-without-catch
2628
this.session.loadUserTask.perform();
29+
30+
// trigger the preload task, but don't wait for the task to finish.
31+
this.preloadPlaygroundCratesTask.perform().catch(() => {
32+
// ignore all errors since we're only preloading here
33+
});
2734
}
2835

2936
@action loading(transition) {
3037
this.progress.handle(transition);
3138
return true;
3239
}
40+
41+
@task(function* () {
42+
yield rawTimeout(1000);
43+
yield this.playground.loadCratesTask.perform();
44+
})
45+
preloadPlaygroundCratesTask;
3346
}

app/services/playground.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { alias } from '@ember/object/computed';
2+
import Service from '@ember/service';
3+
4+
import { task } from 'ember-concurrency';
5+
6+
import ajax from '../utils/ajax';
7+
8+
export default class PlaygroundService extends Service {
9+
@alias('loadCratesTask.lastSuccessful.value') crates;
10+
11+
@(task(function* () {
12+
let response = yield ajax('https://play.rust-lang.org/meta/crates');
13+
return response.crates;
14+
}).drop())
15+
loadCratesTask;
16+
}

app/utils/ajax.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,26 @@ export class AjaxError extends Error {
4646
this.cause = cause;
4747
}
4848

49+
get isJsonError() {
50+
return this.cause instanceof SyntaxError;
51+
}
52+
53+
get isNetworkError() {
54+
return this.cause instanceof TypeError;
55+
}
56+
57+
get isHttpError() {
58+
return this.cause instanceof HttpError;
59+
}
60+
61+
get isServerError() {
62+
return this.isHttpError && this.cause.response.status >= 500 && this.cause.response.status < 600;
63+
}
64+
65+
get isClientError() {
66+
return this.isHttpError && this.cause.response.status >= 400 && this.cause.response.status < 500;
67+
}
68+
4969
async json() {
5070
try {
5171
return await this.cause.response.json();

config/nginx.conf.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ http {
211211
add_header X-Frame-Options "SAMEORIGIN";
212212
add_header X-XSS-Protection "1; mode=block";
213213

214-
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'";
214+
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'";
215215
add_header Access-Control-Allow-Origin "*";
216216

217217
add_header Strict-Transport-Security "max-age=31536000" always;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { render, settled, waitFor } from '@ember/test-helpers';
2+
import { module, test } from 'qunit';
3+
4+
import { defer } from 'rsvp';
5+
6+
import { hbs } from 'ember-cli-htmlbars';
7+
8+
import { setupRenderingTest } from 'cargo/tests/helpers';
9+
10+
import setupMirage from '../../helpers/setup-mirage';
11+
12+
module('Component | CrateSidebar | Playground Button', function (hooks) {
13+
setupRenderingTest(hooks);
14+
setupMirage(hooks);
15+
16+
hooks.beforeEach(function () {
17+
let crates = [
18+
{ name: 'addr2line', version: '0.14.1', id: 'addr2line' },
19+
{ name: 'adler', version: '0.2.3', id: 'adler' },
20+
{ name: 'adler32', version: '1.2.0', id: 'adler32' },
21+
{ name: 'ahash', version: '0.4.7', id: 'ahash' },
22+
{ name: 'aho-corasick', version: '0.7.15', id: 'aho_corasick' },
23+
{ name: 'ansi_term', version: '0.12.1', id: 'ansi_term' },
24+
{ name: 'ansi_term', version: '0.11.0', id: 'ansi_term_0_11_0' },
25+
];
26+
27+
this.server.get('https://play.rust-lang.org/meta/crates', { crates });
28+
});
29+
30+
test('button is hidden for unavailable crates', async function (assert) {
31+
let crate = this.server.create('crate', { name: 'foo' });
32+
this.server.create('version', { crate, num: '1.0.0' });
33+
34+
let store = this.owner.lookup('service:store');
35+
this.crate = await store.findRecord('crate', crate.name);
36+
this.version = (await this.crate.versions).firstObject;
37+
38+
await render(hbs`<CrateSidebar @crate={{this.crate}} @version={{this.version}} />`);
39+
assert.dom('[data-test-playground-button]').doesNotExist();
40+
assert.dom('[data-test-playground-help]').doesNotExist();
41+
});
42+
43+
test('button is visible for available crates', async function (assert) {
44+
let crate = this.server.create('crate', { name: 'aho-corasick' });
45+
this.server.create('version', { crate, num: '1.0.0' });
46+
47+
let store = this.owner.lookup('service:store');
48+
this.crate = await store.findRecord('crate', crate.name);
49+
this.version = (await this.crate.versions).firstObject;
50+
51+
let expectedHref =
52+
'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';
53+
54+
await render(hbs`<CrateSidebar @crate={{this.crate}} @version={{this.version}} />`);
55+
assert.dom('[data-test-playground-button]').hasAttribute('href', expectedHref);
56+
assert.dom('[data-test-playground-help]').exists();
57+
});
58+
59+
test('button is hidden while Playground request is pending', async function (assert) {
60+
let crate = this.server.create('crate', { name: 'aho-corasick' });
61+
this.server.create('version', { crate, num: '1.0.0' });
62+
63+
let deferred = defer();
64+
this.server.get('https://play.rust-lang.org/meta/crates', deferred.promise);
65+
66+
let store = this.owner.lookup('service:store');
67+
this.crate = await store.findRecord('crate', crate.name);
68+
this.version = (await this.crate.versions).firstObject;
69+
70+
render(hbs`<CrateSidebar @crate={{this.crate}} @version={{this.version}} />`);
71+
await waitFor('[data-test-versions]');
72+
assert.dom('[data-test-playground-button]').doesNotExist();
73+
assert.dom('[data-test-playground-help]').doesNotExist();
74+
75+
deferred.resolve({ crates: [] });
76+
await settled();
77+
});
78+
79+
test('button is hidden if the Playground request fails', async function (assert) {
80+
let crate = this.server.create('crate', { name: 'aho-corasick' });
81+
this.server.create('version', { crate, num: '1.0.0' });
82+
83+
this.server.get('https://play.rust-lang.org/meta/crates', {}, 500);
84+
85+
let store = this.owner.lookup('service:store');
86+
this.crate = await store.findRecord('crate', crate.name);
87+
this.version = (await this.crate.versions).firstObject;
88+
89+
await render(hbs`<CrateSidebar @crate={{this.crate}} @version={{this.version}} />`);
90+
assert.dom('[data-test-playground-button]').doesNotExist();
91+
assert.dom('[data-test-playground-help]').doesNotExist();
92+
});
93+
});

tests/services/playground-test.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { module, test } from 'qunit';
2+
3+
import { setupMirage } from 'ember-cli-mirage/test-support';
4+
5+
import { setupTest } from 'cargo/tests/helpers';
6+
7+
module('Service | Playground', function (hooks) {
8+
setupTest(hooks);
9+
setupMirage(hooks);
10+
11+
hooks.beforeEach(function () {
12+
this.playground = this.owner.lookup('service:playground');
13+
});
14+
15+
test('`crates` are available if the request succeeds', async function (assert) {
16+
let crates = [
17+
{ name: 'addr2line', version: '0.14.1', id: 'addr2line' },
18+
{ name: 'adler', version: '0.2.3', id: 'adler' },
19+
{ name: 'adler32', version: '1.2.0', id: 'adler32' },
20+
{ name: 'ahash', version: '0.4.7', id: 'ahash' },
21+
{ name: 'aho-corasick', version: '0.7.15', id: 'aho_corasick' },
22+
{ name: 'ansi_term', version: '0.12.1', id: 'ansi_term' },
23+
{ name: 'ansi_term', version: '0.11.0', id: 'ansi_term_0_11_0' },
24+
];
25+
26+
this.server.get('https://play.rust-lang.org/meta/crates', { crates }, 200);
27+
28+
await this.playground.loadCratesTask.perform();
29+
assert.deepEqual(this.playground.crates, crates);
30+
});
31+
32+
test('loadCratesTask fails on HTTP error', async function (assert) {
33+
this.server.get('https://play.rust-lang.org/meta/crates', {}, 500);
34+
35+
await assert.rejects(this.playground.loadCratesTask.perform());
36+
assert.strictEqual(this.playground.crates, undefined);
37+
});
38+
});

tests/utils/ajax-test.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ module('ajax()', function (hooks) {
2525
assert.deepEqual(response, { foo: 'bar' });
2626
});
2727

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

3131
await assert.rejects(ajax('/foo'), function (error) {
@@ -37,6 +37,11 @@ module('ajax()', function (hooks) {
3737
assert.equal(error.method, 'GET');
3838
assert.equal(error.url, '/foo');
3939
assert.ok(error.cause);
40+
assert.true(error.isHttpError);
41+
assert.true(error.isServerError);
42+
assert.false(error.isClientError);
43+
assert.false(error.isJsonError);
44+
assert.false(error.isNetworkError);
4045

4146
let { cause } = error;
4247
assert.ok(cause instanceof HttpError);
@@ -50,6 +55,36 @@ module('ajax()', function (hooks) {
5055
});
5156
});
5257

58+
test('throws an `HttpError` for 4xx responses', async function (assert) {
59+
this.server.get('/foo', { foo: 42 }, 404);
60+
61+
await assert.rejects(ajax('/foo'), function (error) {
62+
let expectedMessage = 'GET /foo failed\n\ncaused by: HttpError: GET /foo failed with: 404 Not Found';
63+
64+
assert.ok(error instanceof AjaxError);
65+
assert.equal(error.name, 'AjaxError');
66+
assert.ok(error.message.startsWith(expectedMessage), error.message);
67+
assert.equal(error.method, 'GET');
68+
assert.equal(error.url, '/foo');
69+
assert.ok(error.cause);
70+
assert.true(error.isHttpError);
71+
assert.false(error.isServerError);
72+
assert.true(error.isClientError);
73+
assert.false(error.isJsonError);
74+
assert.false(error.isNetworkError);
75+
76+
let { cause } = error;
77+
assert.ok(cause instanceof HttpError);
78+
assert.equal(cause.name, 'HttpError');
79+
assert.equal(cause.message, 'GET /foo failed with: 404 Not Found');
80+
assert.equal(cause.method, 'GET');
81+
assert.equal(cause.url, '/foo');
82+
assert.ok(cause.response);
83+
assert.equal(cause.response.url, '/foo');
84+
return true;
85+
});
86+
});
87+
5388
test('throws an error for invalid JSON responses', async function (assert) {
5489
this.server.get('/foo', () => '{ foo: 42');
5590

@@ -62,6 +97,11 @@ module('ajax()', function (hooks) {
6297
assert.equal(error.method, 'GET');
6398
assert.equal(error.url, '/foo');
6499
assert.ok(error.cause);
100+
assert.false(error.isHttpError);
101+
assert.false(error.isServerError);
102+
assert.false(error.isClientError);
103+
assert.true(error.isJsonError);
104+
assert.false(error.isNetworkError);
65105

66106
let { cause } = error;
67107
assert.ok(!(cause instanceof HttpError));
@@ -85,6 +125,11 @@ module('ajax()', function (hooks) {
85125
assert.equal(error.method, 'GET');
86126
assert.equal(error.url, '/foo');
87127
assert.ok(error.cause);
128+
assert.false(error.isHttpError);
129+
assert.false(error.isServerError);
130+
assert.false(error.isClientError);
131+
assert.false(error.isJsonError);
132+
assert.true(error.isNetworkError);
88133

89134
let { cause } = error;
90135
assert.ok(!(cause instanceof HttpError));

0 commit comments

Comments
 (0)