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
6 changes: 5 additions & 1 deletion app/components/crate-sidebar.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export default class CrateSidebar extends Component {
</div>
{{/unless}}

{{#if (or this.showHomepage @version.documentationLink @crate.repository)}}
{{#if (or this.showHomepage @version.documentationLink @version.sourceLink @crate.repository)}}
<div class='links'>
{{#if this.showHomepage}}
<Link @title='Homepage' @url={{@crate.homepage}} data-test-homepage-link />
Expand All @@ -172,6 +172,10 @@ export default class CrateSidebar extends Component {
<Link @title='Documentation' @url={{@version.documentationLink}} data-test-docs-link />
{{/if}}

{{#if @version.sourceLink}}
<Link @title='Browse source' @url={{@version.sourceLink}} data-test-source-link />
{{/if}}

{{#if @crate.repository}}
<Link @title='Repository' @url={{@crate.repository}} data-test-repository-link />
{{/if}}
Expand Down
26 changes: 24 additions & 2 deletions app/models/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,12 @@ export default class Version extends Model {
return await ajax(`https://docs.rs/crate/${this.crateName}/=${this.num}/status.json`);
});

get docsRsResponse() {
return this.loadDocsStatusTask.lastSuccessful;
}

get hasDocsRsLink() {
let docsStatus = this.loadDocsStatusTask.lastSuccessful?.value;
return docsStatus?.doc_status === true;
return this.docsRsResponse?.value?.doc_status === true;
}

get docsRsLink() {
Expand Down Expand Up @@ -228,6 +231,25 @@ export default class Version extends Model {
return null;
}

get docsRsSourceLink() {
if (this.docsRsResponse) {
return `https://docs.rs/crate/${this.crateName}/${this.num}/source/`;
}
}

get sourceLink() {
// Return a link to docs.rs if we get any successful response from docs.rs, so that we show
// the source link regardless of this crate being a library or binary, regardless of whether
// the docs built successfully, regardless of whether the build is queued or completed, and
// regardless of whether a documentation link is specified.
let { docsRsSourceLink } = this;
if (docsRsSourceLink) {
return docsRsSourceLink;
}

return null;
}

yankTask = keepLatestTask(async () => {
let data = { version: { yanked: true } };
let payload = await waitForPromise(apiAction(this, { method: 'PATCH', data }));
Expand Down
16 changes: 8 additions & 8 deletions app/routes/crate/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,14 @@ export default class VersionRoute extends Route {
// ignored
});

if (!crate.documentation || crate.documentation.startsWith('https://docs.rs/')) {
version.loadDocsStatusTask.perform().catch(error => {
// report unexpected errors to Sentry and ignore `ajax()` errors
if (!didCancel(error) && !(error instanceof AjaxError)) {
this.sentry.captureException(error);
}
});
}
// Load the status of the docs.rs build even if there's a non-docs.rs documentation link
// specified, so that we can link to the source view on docs.rs (as long as it exists).
version.loadDocsStatusTask.perform().catch(error => {
// report unexpected errors to Sentry and ignore `ajax()` errors
if (!didCancel(error) && !(error instanceof AjaxError)) {
this.sentry.captureException(error);
}
});
}

serialize(model) {
Expand Down
113 changes: 113 additions & 0 deletions e2e/routes/crate/version/source-link.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { expect, test } from '@/e2e/helper';
import { http, HttpResponse } from 'msw';

test.describe('Route | crate.version | source link', { tag: '@routes' }, () => {
test('show docs.rs source link even if non-docs.rs documentation link is specified', async ({ page, msw }) => {
let crate = await msw.db.crate.create({ name: 'foo', documentation: 'https://foo.io/docs' });
await msw.db.version.create({ crate, num: '1.0.0' });

let response = HttpResponse.json({
doc_status: false,
version: '1.0.0',
});
msw.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => response));

await page.goto('/crates/foo');
await expect(page.locator('[data-test-source-link] a')).toHaveAttribute(
'href',
'https://docs.rs/crate/foo/1.0.0/source/',
);
});

test('show no source link if there are no related docs.rs builds', async ({ page, msw }) => {
let crate = await msw.db.crate.create({ name: 'foo' });
await msw.db.version.create({ crate, num: '1.0.0' });

let error = HttpResponse.text('not found', { status: 404 });
msw.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => error));

await page.goto('/crates/foo');
await expect(page.getByRole('link', { name: 'crates.io', exact: true })).toHaveCount(1);

await expect(page.locator('[data-test-source-link] a')).toHaveCount(0);
});

test('show source link if `documentation` is unspecified and there are related docs.rs builds', async ({
page,
msw,
}) => {
let crate = await msw.db.crate.create({ name: 'foo' });
await msw.db.version.create({ crate, num: '1.0.0' });

let response = HttpResponse.json({
doc_status: true,
version: '1.0.0',
});
msw.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => response));

await page.goto('/crates/foo');
await expect(page.locator('[data-test-source-link] a')).toHaveAttribute(
'href',
'https://docs.rs/crate/foo/1.0.0/source/',
);
});

test('show no source link if `documentation` points to docs.rs and there are no related docs.rs builds', async ({
page,
msw,
}) => {
let crate = await msw.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
await msw.db.version.create({ crate, num: '1.0.0' });

let error = HttpResponse.text('not found', { status: 404 });
msw.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => error));

await page.goto('/crates/foo');
await expect(page.locator('[data-test-source-link] a')).toHaveCount(0);
});

test('show source link if `documentation` points to docs.rs and there are related docs.rs builds', async ({
page,
msw,
}) => {
let crate = await msw.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
await msw.db.version.create({ crate, num: '1.0.0' });

let response = HttpResponse.json({
doc_status: true,
version: '1.0.0',
});
msw.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => response));

await page.goto('/crates/foo');
await expect(page.locator('[data-test-source-link] a')).toHaveAttribute(
'href',
'https://docs.rs/crate/foo/1.0.0/source/',
);
});

test('ajax errors are ignored, but show no source link', async ({ page, msw }) => {
let crate = await msw.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
await msw.db.version.create({ crate, num: '1.0.0' });

let error = HttpResponse.text('error', { status: 500 });
msw.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => error));

await page.goto('/crates/foo');
await expect(page.locator('[data-test-source-link] a')).toHaveCount(0);
});

test('empty docs.rs responses are ignored, still show source link', async ({ page, msw }) => {
let crate = await msw.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
await msw.db.version.create({ crate, num: '0.6.2' });

let response = HttpResponse.json({});
msw.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => response));

await page.goto('/crates/foo');
await expect(page.locator('[data-test-source-link] a')).toHaveAttribute(
'href',
'https://docs.rs/crate/foo/0.6.2/source/',
);
});
});
87 changes: 87 additions & 0 deletions tests/routes/crate/version/source-link-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { visit } from '@ember/test-helpers';
import { module, test } from 'qunit';

import { http, HttpResponse } from 'msw';

import { setupApplicationTest } from 'crates-io/tests/helpers';

module('Route | crate.version | source link', function (hooks) {
setupApplicationTest(hooks);

test('shows docs.rs source link even if non-docs.rs documentation link is specified', async function (assert) {
let crate = await this.db.crate.create({ name: 'foo', documentation: 'https://foo.io/docs' });
await this.db.version.create({ crate, num: '1.0.0' });

let response = HttpResponse.json({ doc_status: false, version: '1.0.0' });
this.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => response));

await visit('/crates/foo');
assert.dom('[data-test-source-link] a').hasAttribute('href', 'https://docs.rs/crate/foo/1.0.0/source/');
});

test('show no source link if there are no related docs.rs builds', async function (assert) {
let crate = await this.db.crate.create({ name: 'foo' });
await this.db.version.create({ crate, num: '1.0.0' });

let error = HttpResponse.text('not found', { status: 404 });
this.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => error));

await visit('/crates/foo');
assert.dom('[data-test-source-link] a').doesNotExist();
});

test('show source link if `documentation` is unspecified and there are related docs.rs builds', async function (assert) {
let crate = await this.db.crate.create({ name: 'foo' });
await this.db.version.create({ crate, num: '1.0.0' });

let response = HttpResponse.json({ doc_status: true, version: '1.0.0' });
this.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => response));

await visit('/crates/foo');
assert.dom('[data-test-source-link] a').hasAttribute('href', 'https://docs.rs/crate/foo/1.0.0/source/');
});

test('show no source link if `documentation` points to docs.rs and there are no related docs.rs builds', async function (assert) {
let crate = await this.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
await this.db.version.create({ crate, num: '1.0.0' });

let error = HttpResponse.text('not found', { status: 404 });
this.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => error));

await visit('/crates/foo');
assert.dom('[data-test-source-link] a').doesNotExist();
});

test('show source link if `documentation` points to docs.rs and there is a successful docs.rs response', async function (assert) {
let crate = await this.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
await this.db.version.create({ crate, num: '1.0.0' });

let response = HttpResponse.json({ doc_status: false, version: '1.0.0' });
this.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => response));

await visit('/crates/foo');
assert.dom('[data-test-source-link] a').hasAttribute('href', 'https://docs.rs/crate/foo/1.0.0/source/');
});

test('ajax errors are ignored, but show no source link', async function (assert) {
let crate = await this.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
await this.db.version.create({ crate, num: '1.0.0' });

let error = HttpResponse.text('error', { status: 500 });
this.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => error));

await visit('/crates/foo');
assert.dom('[data-test-source-link] a').doesNotExist();
});

test('empty docs.rs responses are ignored, still show source link', async function (assert) {
let crate = await this.db.crate.create({ name: 'foo', documentation: 'https://docs.rs/foo/0.6.2' });
await this.db.version.create({ crate, num: '0.6.2' });

let response = HttpResponse.json({});
this.worker.use(http.get('https://docs.rs/crate/:crate/:version/status.json', () => response));

await visit('/crates/foo');
assert.dom('[data-test-source-link] a').hasAttribute('href', 'https://docs.rs/crate/foo/0.6.2/source/');
});
});
Loading