From 8b2d149751adeff9ac6a16ef52034668508dbfcb Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 28 Jan 2021 17:38:12 +0100 Subject: [PATCH 1/5] utils/ajax: Add error identification properties --- app/utils/ajax.js | 20 +++++++++++++++++ tests/utils/ajax-test.js | 47 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/app/utils/ajax.js b/app/utils/ajax.js index 106b3949a6a..eb57d1f3d25 100644 --- a/app/utils/ajax.js +++ b/app/utils/ajax.js @@ -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(); diff --git a/tests/utils/ajax-test.js b/tests/utils/ajax-test.js index d2a0e2c9905..fb5b9b9ec88 100644 --- a/tests/utils/ajax-test.js +++ b/tests/utils/ajax-test.js @@ -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) { @@ -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); @@ -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'); @@ -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)); @@ -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)); From d316cf8707c7f228535ab52eb36af7785a4bac1e Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 28 Jan 2021 18:24:18 +0100 Subject: [PATCH 2/5] nginx: Add "Rust Playground" to CSP `connect-src` list --- config/nginx.conf.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/nginx.conf.erb b/config/nginx.conf.erb index 8e96bb040c8..765713049c6 100644 --- a/config/nginx.conf.erb +++ b/config/nginx.conf.erb @@ -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; From 66c0dab3895d2ab93c0f572634d785a96010a114 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 28 Jan 2021 18:05:06 +0100 Subject: [PATCH 3/5] Add `playground` service --- app/services/playground.js | 16 +++++++++++++ tests/services/playground-test.js | 38 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 app/services/playground.js create mode 100644 tests/services/playground-test.js diff --git a/app/services/playground.js b/app/services/playground.js new file mode 100644 index 00000000000..5d8ee3b55e4 --- /dev/null +++ b/app/services/playground.js @@ -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; +} diff --git a/tests/services/playground-test.js b/tests/services/playground-test.js new file mode 100644 index 00000000000..ca063956950 --- /dev/null +++ b/tests/services/playground-test.js @@ -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); + }); +}); From 6eda15c03f65b28d6e92ef343a0b1ce127a1545d Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 28 Jan 2021 18:05:48 +0100 Subject: [PATCH 4/5] CrateSidebar: Show "Try on Rust Playground" button for crates that are available there --- app/components/crate-sidebar.hbs | 18 ++++ app/components/crate-sidebar.js | 30 ++++++ app/components/crate-sidebar.module.css | 14 +++ .../crate-sidebar/playground-button-test.js | 93 +++++++++++++++++++ 4 files changed, 155 insertions(+) create mode 100644 tests/components/crate-sidebar/playground-button-test.js diff --git a/app/components/crate-sidebar.hbs b/app/components/crate-sidebar.hbs index e0da2d854fe..e857a2d34bf 100644 --- a/app/components/crate-sidebar.hbs +++ b/app/components/crate-sidebar.hbs @@ -119,4 +119,22 @@ {{/if}} {{/unless}} + + {{#if this.playgroundLink}} +
+ + Try on Rust Playground + +

+ The top 100 crates are available on the Rust Playground for you to + try out directly in your browser. +

+
+ {{/if}} \ No newline at end of file diff --git a/app/components/crate-sidebar.js b/app/components/crate-sidebar.js index 654f989eea2..0ddbc27c5ff 100644 --- a/app/components/crate-sidebar.js +++ b/app/components/crate-sidebar.js @@ -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') @@ -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); + } + }); + } + } } diff --git a/app/components/crate-sidebar.module.css b/app/components/crate-sidebar.module.css index 456c0a92c91..45c65099406 100644 --- a/app/components/crate-sidebar.module.css +++ b/app/components/crate-sidebar.module.css @@ -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; +} diff --git a/tests/components/crate-sidebar/playground-button-test.js b/tests/components/crate-sidebar/playground-button-test.js new file mode 100644 index 00000000000..f470292959a --- /dev/null +++ b/tests/components/crate-sidebar/playground-button-test.js @@ -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``); + 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``); + 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``); + 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``); + assert.dom('[data-test-playground-button]').doesNotExist(); + assert.dom('[data-test-playground-help]').doesNotExist(); + }); +}); From 76bbf596e1922d9b728b665b74248bd8ce4d4e6e Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 28 Jan 2021 18:06:26 +0100 Subject: [PATCH 5/5] routes/application: Preload Rust Playground crates list after the app has been open for 1 sec --- app/routes/application.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/routes/application.js b/app/routes/application.js index ceb6e39addc..111260609d9 100644 --- a/app/routes/application.js +++ b/app/routes/application.js @@ -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', () => { @@ -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; }