diff --git a/app/components/crate-sidebar.hbs b/app/components/crate-sidebar.hbs
index 1c1d068437b..1ace1a533e7 100644
--- a/app/components/crate-sidebar.hbs
+++ b/app/components/crate-sidebar.hbs
@@ -130,11 +130,13 @@
{{/unless}}
{{/if}}
-
Report crate
-
+
\ No newline at end of file
diff --git a/app/components/footer.hbs b/app/components/footer.hbs
index 93551e6bad8..2e0cc9b2680 100644
--- a/app/components/footer.hbs
+++ b/app/components/footer.hbs
@@ -13,7 +13,7 @@
Get Help
diff --git a/app/components/footer.js b/app/components/footer.js
new file mode 100644
index 00000000000..be7f32a6ce4
--- /dev/null
+++ b/app/components/footer.js
@@ -0,0 +1,11 @@
+import { inject as service } from '@ember/service';
+import Component from '@glimmer/component';
+
+export default class Footer extends Component {
+ @service pristineQuery;
+
+ get pristineSupportQuery() {
+ let params = this.pristineQuery.paramsFor('support');
+ return params;
+ }
+}
diff --git a/app/components/support/crate-report-form.hbs b/app/components/support/crate-report-form.hbs
new file mode 100644
index 00000000000..499579d5762
--- /dev/null
+++ b/app/components/support/crate-report-form.hbs
@@ -0,0 +1,89 @@
+
\ No newline at end of file
diff --git a/app/components/support/crate-report-form.js b/app/components/support/crate-report-form.js
new file mode 100644
index 00000000000..0d5ebfe9910
--- /dev/null
+++ b/app/components/support/crate-report-form.js
@@ -0,0 +1,105 @@
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+
+import window from 'ember-window-mock';
+
+const REASONS = [
+ {
+ reason: 'spam',
+ description: 'it contains spam',
+ },
+ {
+ reason: 'name-squatting',
+ description: 'it is name-squatting (reserving a crate name without content)',
+ },
+ {
+ reason: 'abuse',
+ description: 'it is abusive or otherwise harmful',
+ },
+ {
+ reason: 'security',
+ description: 'it contains a vulnerability (please try to contact the crate author first)',
+ },
+ {
+ reason: 'other',
+ description: 'it is violating the usage policy in some other way (please specify below)',
+ },
+];
+
+export default class CrateReportForm extends Component {
+ @service store;
+
+ @tracked crate = '';
+ @tracked selectedReasons = [];
+ @tracked detail = '';
+ @tracked crateInvalid = false;
+ @tracked reasonsInvalid = false;
+ @tracked detailInvalid = false;
+
+ reasons = REASONS;
+
+ constructor() {
+ super(...arguments);
+ this.crate = this.args.crate;
+ }
+
+ validate() {
+ this.crateInvalid = !this.crate || !this.crate.trim();
+ this.reasonsInvalid = this.selectedReasons.length === 0;
+ this.detailInvalid = this.selectedReasons.includes('other') && !this.detail?.trim();
+ return !this.crateInvalid && !this.reasonsInvalid && !this.detailInvalid;
+ }
+
+ @action resetCrateValidation() {
+ this.crateInvalid = false;
+ }
+
+ @action resetDetailValidation() {
+ this.detailInvalid = false;
+ }
+
+ @action isReasonSelected(reason) {
+ return this.selectedReasons.includes(reason);
+ }
+
+ @action toggleReason(reason) {
+ this.selectedReasons = this.selectedReasons.includes(reason)
+ ? this.selectedReasons.filter(it => it !== reason)
+ : [...this.selectedReasons, reason];
+ this.reasonsInvalid = false;
+ }
+
+ @action
+ submit() {
+ if (!this.validate()) {
+ return;
+ }
+
+ let mailto = this.composeMail();
+ window.open(mailto, '_self');
+ }
+
+ composeMail() {
+ let crate = this.crate;
+ let reasons = this.reasons
+ .map(({ reason, description }) => {
+ let selected = this.isReasonSelected(reason);
+ return `${selected ? '- [x]' : '- [ ]'} ${description}`;
+ })
+ .join('\n');
+ let body = `I'm reporting the https://crates.io/crates/${crate} crate because:
+
+${reasons}
+
+Additional details:
+
+${this.detail}
+`;
+ let subject = `The "${crate}" crate`;
+ let address = 'help@crates.io';
+ let mailto = `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ return mailto;
+ }
+}
diff --git a/app/components/support/crate-report-form.module.css b/app/components/support/crate-report-form.module.css
new file mode 100644
index 00000000000..033d36ef7ca
--- /dev/null
+++ b/app/components/support/crate-report-form.module.css
@@ -0,0 +1,76 @@
+.report-form {
+ background-color: var(--main-bg);
+ padding: 0.5rem 1rem;
+}
+
+.form-group {
+ border: none;
+ margin: 0;
+ padding: 0;
+
+ & + & {
+ margin-top: 1rem;
+ }
+}
+
+.form-group-name {
+ composes: form-group-name from '../../styles/settings/tokens/new.module.css';
+ align-items: center;
+}
+
+.crate-input {
+ composes: name-input from '../../styles/settings/tokens/new.module.css';
+}
+
+.reasons-list {
+ composes: scopes-list from '../../styles/settings/tokens/new.module.css';
+ label {
+ flex-wrap: nowrap;
+ }
+ input {
+ align-self: center;
+ }
+}
+
+.detail {
+ padding: var(--space-2xs);
+ background-color: light-dark(white, #141413);
+ border: 1px solid var(--gray-border);
+ border-radius: var(--space-3xs);
+ resize: vertical;
+ width: 100%;
+
+ &.invalid {
+ background: light-dark(#fff2f2, #170808);
+ border-color: red;
+ }
+}
+
+.form-group-error {
+ composes: form-group-error from '../../styles/settings/tokens/new.module.css';
+}
+
+.buttons {
+ composes: buttons from '../../styles/settings/tokens/new.module.css';
+ justify-content: end;
+ gap: 2rem;
+}
+
+.button {
+ &:focus {
+ outline: 1px solid var(--bg-color-top-dark);
+ outline-offset: 2px;
+ }
+}
+
+.report-button {
+ composes: button;
+ composes: button small from '../../styles/shared/buttons.module.css';
+ border-radius: var(--space-3xs);
+}
+
+.cancel-button {
+ composes: button;
+ composes: tan-button small from '../../styles/shared/buttons.module.css';
+ border-radius: var(--space-3xs);
+}
diff --git a/app/controllers/support.js b/app/controllers/support.js
new file mode 100644
index 00000000000..7fd2796f1b6
--- /dev/null
+++ b/app/controllers/support.js
@@ -0,0 +1,24 @@
+import Controller from '@ember/controller';
+import { tracked } from '@glimmer/tracking';
+
+const SUPPORTS = [
+ {
+ inquire: 'crate-violation',
+ label: 'Report a crate that violates policies',
+ },
+];
+
+const VALID_INQUIRE = new Set(SUPPORTS.map(s => s.inquire));
+
+export default class SupportController extends Controller {
+ queryParams = ['inquire', 'crate'];
+
+ @tracked inquire;
+ @tracked crate;
+
+ supports = SUPPORTS;
+
+ get supported() {
+ return VALID_INQUIRE.has(this.inquire);
+ }
+}
diff --git a/app/router.js b/app/router.js
index 0380750dd40..e0de0c9c4bd 100644
--- a/app/router.js
+++ b/app/router.js
@@ -60,6 +60,7 @@ Router.map(function () {
this.route('data-access');
this.route('confirm', { path: '/confirm/:email_token' });
this.route('accept-invite', { path: '/accept-invite/:token' });
+ this.route('support');
this.route('catch-all', { path: '*path' });
});
diff --git a/app/routes/support.js b/app/routes/support.js
new file mode 100644
index 00000000000..5f328670010
--- /dev/null
+++ b/app/routes/support.js
@@ -0,0 +1,13 @@
+import Route from '@ember/routing/route';
+
+export default class CrateRoute extends Route {
+ resetController(controller, isExiting) {
+ super.resetController(...arguments);
+ // reset queryParams when exiting
+ if (isExiting) {
+ for (let param of controller.queryParams) {
+ controller.set(param, null);
+ }
+ }
+ }
+}
diff --git a/app/services/pristine-query.js b/app/services/pristine-query.js
new file mode 100644
index 00000000000..21d545dae09
--- /dev/null
+++ b/app/services/pristine-query.js
@@ -0,0 +1,9 @@
+import { getOwner } from '@ember/owner';
+import Service from '@ember/service';
+
+export default class PristineParamsService extends Service {
+ paramsFor(route) {
+ let params = getOwner(this).lookup(`controller:${route}`)?.queryParams || [];
+ return Object.fromEntries(params.map(k => [k, null]));
+ }
+}
diff --git a/app/styles/support.module.css b/app/styles/support.module.css
new file mode 100644
index 00000000000..36c459242ec
--- /dev/null
+++ b/app/styles/support.module.css
@@ -0,0 +1,15 @@
+.inquire-list {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-s);
+ list-style: none;
+ padding: 0;
+}
+
+.link {
+ composes: link from '../components/front-page-list/item.module.css';
+ justify-content: center;
+ padding: var(--space-xs) var(--space-s);
+ height: inherit;
+ min-height: var(--space-2xl);
+}
diff --git a/app/templates/support.hbs b/app/templates/support.hbs
new file mode 100644
index 00000000000..231315f5597
--- /dev/null
+++ b/app/templates/support.hbs
@@ -0,0 +1,38 @@
+
+
+
+ {{#if this.supported}}
+ {{#if (eq this.inquire "crate-violation")}}
+
+ {{/if}}
+ {{else}}
+
+ Choose one of the these categories to continue.
+
+
+ {{/if}}
+
\ No newline at end of file
diff --git a/e2e/acceptance/support.spec.ts b/e2e/acceptance/support.spec.ts
new file mode 100644
index 00000000000..2259f75b572
--- /dev/null
+++ b/e2e/acceptance/support.spec.ts
@@ -0,0 +1,328 @@
+import { test, expect } from '@/e2e/helper';
+
+test.describe('Acceptance | support page', { tag: '@acceptance' }, () => {
+ test('shows an inquire list', async ({ page, percy, a11y }) => {
+ await page.goto('/support');
+ await expect(page).toHaveURL('/support');
+
+ await expect(page.getByTestId('support-main-content').locator('section')).toHaveCount(1);
+ await expect(page.getByTestId('inquire-list-section')).toBeVisible();
+ const inquireList = page.getByTestId('inquire-list');
+ await expect(inquireList).toBeVisible();
+ await expect(inquireList.locator(page.getByRole('listitem'))).toHaveText(
+ ['Report a crate that violates policies'].concat(['For all other cases']),
+ );
+
+ await percy.snapshot();
+ await a11y.audit();
+ });
+
+ test('shows an inquire list if given inquire is not supported', async ({ page }) => {
+ await page.goto('/support?inquire=not-supported-inquire');
+ await expect(page).toHaveURL('/support?inquire=not-supported-inquire');
+
+ await expect(page.getByTestId('support-main-content').locator('section')).toHaveCount(1);
+ await expect(page.getByTestId('inquire-list-section')).toBeVisible();
+ const inquireList = page.getByTestId('inquire-list');
+ await expect(inquireList).toBeVisible();
+ await expect(inquireList.locator(page.getByRole('listitem'))).toHaveText(
+ ['Report a crate that violates policies'].concat(['For all other cases']),
+ );
+ });
+
+ test.describe('reporting a crate from support page', () => {
+ test.beforeEach(async ({ page, mirage }) => {
+ await mirage.config({ trackRequests: true });
+ await mirage.addHook(server => {
+ globalThis._routes = server._config.routes;
+ let crate = server.create('crate', { name: 'nanomsg' });
+ server.create('version', { crate, num: '0.6.0' });
+ });
+ // mock `window.open()`
+ await page.addInitScript(() => {
+ globalThis.open = (url, target, features) => {
+ globalThis.openKwargs = { url, target, features };
+ return { document: { write() {}, close() {} }, close() {} } as ReturnType<(typeof globalThis)['open']>;
+ };
+ });
+
+ await page.goto('/support');
+ await page.getByTestId('link-crate-violation').click();
+ await expect(page).toHaveURL('/support?inquire=crate-violation');
+ });
+
+ test('show a report form', async ({ page, percy, a11y }) => {
+ await expect(page.getByTestId('support-main-content').locator('section')).toHaveCount(1);
+ await expect(page.getByTestId('crate-violation-section')).toBeVisible();
+ await expect(page.getByTestId('fieldset-crate')).toBeVisible();
+ await expect(page.getByTestId('fieldset-reasons')).toBeVisible();
+ await expect(page.getByTestId('fieldset-detail')).toBeVisible();
+ await expect(page.getByTestId('report-button')).toHaveText('Report');
+
+ await percy.snapshot();
+ await a11y.audit();
+ });
+
+ test('empty form should shows errors', async ({ page }) => {
+ await page.getByTestId('report-button').click();
+
+ await expect(page.getByTestId('crate-invalid')).toBeVisible();
+ await expect(page.getByTestId('reasons-invalid')).toBeVisible();
+ await expect(page.getByTestId('detail-invalid')).not.toBeVisible();
+
+ await page.waitForFunction(() => globalThis.openKwargs === undefined);
+ });
+
+ test('empty crate should shows errors', async ({ page }) => {
+ const crateInput = page.getByTestId('crate-input');
+ await expect(crateInput).toHaveValue('');
+ const reportButton = page.getByTestId('report-button');
+ await reportButton.click();
+
+ await expect(page.getByTestId('crate-invalid')).toBeVisible();
+ await expect(page.getByTestId('reasons-invalid')).toBeVisible();
+ await expect(page.getByTestId('detail-invalid')).not.toBeVisible();
+
+ await page.waitForFunction(() => globalThis.openKwargs === undefined);
+ });
+
+ test('other reason selected without given detail shows an error', async ({ page }) => {
+ const crateInput = page.getByTestId('crate-input');
+ await crateInput.fill('nanomsg');
+ await expect(crateInput).toHaveValue('nanomsg');
+
+ const spam = page.getByTestId('spam-checkbox');
+ await spam.check();
+ await expect(spam).toBeChecked();
+ const other = page.getByTestId('other-checkbox');
+ await other.check();
+ await expect(other).toBeChecked();
+ const detailInput = page.getByTestId('detail-input');
+ await expect(detailInput).toHaveValue('');
+ const reportButton = page.getByTestId('report-button');
+ await reportButton.click();
+
+ await expect(page.getByTestId('crate-invalid')).not.toBeVisible();
+ await expect(page.getByTestId('reasons-invalid')).not.toBeVisible();
+ await expect(page.getByTestId('detail-invalid')).toBeVisible();
+
+ await page.waitForFunction(() => globalThis.openKwargs === undefined);
+ });
+
+ test('valid form without detail', async ({ page }) => {
+ const crateInput = page.getByTestId('crate-input');
+ await crateInput.fill('nanomsg');
+ await expect(crateInput).toHaveValue('nanomsg');
+
+ const spam = page.getByTestId('spam-checkbox');
+ await spam.check();
+ await expect(spam).toBeChecked();
+ const detailInput = page.getByTestId('detail-input');
+ await expect(detailInput).toHaveValue('');
+
+ await page.waitForFunction(() => globalThis.openKwargs === undefined);
+ const reportButton = page.getByTestId('report-button');
+ await reportButton.click();
+
+ await expect(page.getByTestId('crate-invalid')).not.toBeVisible();
+ await expect(page.getByTestId('reasons-invalid')).not.toBeVisible();
+ await expect(page.getByTestId('detail-invalid')).not.toBeVisible();
+
+ let body = `I'm reporting the https://crates.io/crates/nanomsg crate because:
+
+- [x] it contains spam
+- [ ] it is name-squatting (reserving a crate name without content)
+- [ ] it is abusive or otherwise harmful
+- [ ] it contains a vulnerability (please try to contact the crate author first)
+- [ ] it is violating the usage policy in some other way (please specify below)
+
+Additional details:
+
+
+`;
+ let subject = `The "nanomsg" crate`;
+ let address = 'help@crates.io';
+ let mailto = `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ // wait for `window.open()` to be called
+ await page.waitForFunction(() => !!globalThis.openKwargs);
+ await page.waitForFunction(expect => globalThis.openKwargs.url === expect, mailto);
+ await page.waitForFunction(expect => globalThis.openKwargs.target === expect, '_self');
+ });
+
+ test('valid form with required detail', async ({ page }) => {
+ const crateInput = page.getByTestId('crate-input');
+ await crateInput.fill('nanomsg');
+ await expect(crateInput).toHaveValue('nanomsg');
+
+ const spam = page.getByTestId('spam-checkbox');
+ await spam.check();
+ await expect(spam).toBeChecked();
+ const other = page.getByTestId('other-checkbox');
+ await other.check();
+ await expect(other).toBeChecked();
+ const detailInput = page.getByTestId('detail-input');
+ await detailInput.fill('test detail');
+ await expect(detailInput).toHaveValue('test detail');
+
+ await page.waitForFunction(() => globalThis.openKwargs === undefined);
+ const reportButton = page.getByTestId('report-button');
+ await reportButton.click();
+
+ await expect(page.getByTestId('crate-invalid')).not.toBeVisible();
+ await expect(page.getByTestId('reasons-invalid')).not.toBeVisible();
+ await expect(page.getByTestId('detail-invalid')).not.toBeVisible();
+
+ let body = `I'm reporting the https://crates.io/crates/nanomsg crate because:
+
+- [x] it contains spam
+- [ ] it is name-squatting (reserving a crate name without content)
+- [ ] it is abusive or otherwise harmful
+- [ ] it contains a vulnerability (please try to contact the crate author first)
+- [x] it is violating the usage policy in some other way (please specify below)
+
+Additional details:
+
+test detail
+`;
+ let subject = `The "nanomsg" crate`;
+ let address = 'help@crates.io';
+ let mailto = `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ // wait for `window.open()` to be called
+ await page.waitForFunction(() => !!globalThis.openKwargs);
+ await page.waitForFunction(expect => globalThis.openKwargs.url === expect, mailto);
+ await page.waitForFunction(expect => globalThis.openKwargs.target === expect, '_self');
+ });
+ });
+
+ test.describe('reporting a crate from crate page', () => {
+ test.beforeEach(async ({ page, mirage }) => {
+ await mirage.config({ trackRequests: true });
+ await mirage.addHook(server => {
+ globalThis._routes = server._config.routes;
+ let crate = server.create('crate', { name: 'nanomsg' });
+ server.create('version', { crate, num: '0.6.0' });
+ });
+ // mock `window.open()`
+ await page.addInitScript(() => {
+ globalThis.open = (url, target, features) => {
+ globalThis.openKwargs = { url, target, features };
+ return { document: { write() {}, close() {} }, close() {} } as ReturnType<(typeof globalThis)['open']>;
+ };
+ });
+
+ await page.goto('/crates/nanomsg');
+ await page.getByTestId('link-crate-report').click();
+ await expect(page).toHaveURL('/support?crate=nanomsg&inquire=crate-violation');
+ await expect(page.getByTestId('crate-input')).toHaveValue('nanomsg');
+ });
+
+ test('empty crate should shows errors', async ({ page }) => {
+ const crateInput = page.getByTestId('crate-input');
+ await crateInput.fill('');
+ await expect(crateInput).toHaveValue('');
+ const reportButton = page.getByTestId('report-button');
+ await reportButton.click();
+
+ await expect(page.getByTestId('crate-invalid')).toBeVisible();
+ await expect(page.getByTestId('reasons-invalid')).toBeVisible();
+ await expect(page.getByTestId('detail-invalid')).not.toBeVisible();
+
+ await page.waitForFunction(() => globalThis.openKwargs === undefined);
+ });
+
+ test('other reason selected without given detail shows an error', async ({ page }) => {
+ const spam = page.getByTestId('spam-checkbox');
+ await spam.check();
+ await expect(spam).toBeChecked();
+ const other = page.getByTestId('other-checkbox');
+ await other.check();
+ await expect(other).toBeChecked();
+ const detailInput = page.getByTestId('detail-input');
+ await expect(detailInput).toHaveValue('');
+ const reportButton = page.getByTestId('report-button');
+ await reportButton.click();
+
+ await expect(page.getByTestId('crate-invalid')).not.toBeVisible();
+ await expect(page.getByTestId('reasons-invalid')).not.toBeVisible();
+ await expect(page.getByTestId('detail-invalid')).toBeVisible();
+
+ await page.waitForFunction(() => globalThis.openKwargs === undefined);
+ });
+
+ test('valid form without detail', async ({ page }) => {
+ const spam = page.getByTestId('spam-checkbox');
+ await spam.check();
+ await expect(spam).toBeChecked();
+ const detailInput = page.getByTestId('detail-input');
+ await expect(detailInput).toHaveValue('');
+
+ await page.waitForFunction(() => globalThis.openKwargs === undefined);
+ const reportButton = page.getByTestId('report-button');
+ await reportButton.click();
+
+ await expect(page.getByTestId('crate-invalid')).not.toBeVisible();
+ await expect(page.getByTestId('reasons-invalid')).not.toBeVisible();
+ await expect(page.getByTestId('detail-invalid')).not.toBeVisible();
+
+ let body = `I'm reporting the https://crates.io/crates/nanomsg crate because:
+
+- [x] it contains spam
+- [ ] it is name-squatting (reserving a crate name without content)
+- [ ] it is abusive or otherwise harmful
+- [ ] it contains a vulnerability (please try to contact the crate author first)
+- [ ] it is violating the usage policy in some other way (please specify below)
+
+Additional details:
+
+
+`;
+ let subject = `The "nanomsg" crate`;
+ let address = 'help@crates.io';
+ let mailto = `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ // wait for `window.open()` to be called
+ await page.waitForFunction(() => !!globalThis.openKwargs);
+ await page.waitForFunction(expect => globalThis.openKwargs.url === expect, mailto);
+ await page.waitForFunction(expect => globalThis.openKwargs.target === expect, '_self');
+ });
+
+ test('valid form with required detail', async ({ page }) => {
+ const spam = page.getByTestId('spam-checkbox');
+ await spam.check();
+ await expect(spam).toBeChecked();
+ const other = page.getByTestId('other-checkbox');
+ await other.check();
+ await expect(other).toBeChecked();
+ const detailInput = page.getByTestId('detail-input');
+ await detailInput.fill('test detail');
+ await expect(detailInput).toHaveValue('test detail');
+
+ await page.waitForFunction(() => globalThis.openKwargs === undefined);
+ const reportButton = page.getByTestId('report-button');
+ await reportButton.click();
+
+ await expect(page.getByTestId('crate-invalid')).not.toBeVisible();
+ await expect(page.getByTestId('reasons-invalid')).not.toBeVisible();
+ await expect(page.getByTestId('detail-invalid')).not.toBeVisible();
+
+ let body = `I'm reporting the https://crates.io/crates/nanomsg crate because:
+
+- [x] it contains spam
+- [ ] it is name-squatting (reserving a crate name without content)
+- [ ] it is abusive or otherwise harmful
+- [ ] it contains a vulnerability (please try to contact the crate author first)
+- [x] it is violating the usage policy in some other way (please specify below)
+
+Additional details:
+
+test detail
+`;
+ let subject = `The "nanomsg" crate`;
+ let address = 'help@crates.io';
+ let mailto = `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ // wait for `window.open()` to be called
+ await page.waitForFunction(() => !!globalThis.openKwargs);
+ await page.waitForFunction(expect => globalThis.openKwargs.url === expect, mailto);
+ await page.waitForFunction(expect => globalThis.openKwargs.target === expect, '_self');
+ });
+ });
+});
diff --git a/e2e/routes/support.spec.ts b/e2e/routes/support.spec.ts
new file mode 100644
index 00000000000..c94ae28f514
--- /dev/null
+++ b/e2e/routes/support.spec.ts
@@ -0,0 +1,56 @@
+import { test, expect } from '@/e2e/helper';
+
+test.describe('Route | support', { tag: '@routes' }, () => {
+ test('should not retain query params when exiting and then returning', async ({ page }) => {
+ await page.goto('/support?inquire=crate-violation');
+ await expect(page).toHaveURL('/support?inquire=crate-violation');
+ let section = page.getByTestId('support-main-content').locator('section');
+ await expect(section).toHaveCount(1);
+ await expect(section).toHaveAttribute('data-test-id', 'crate-violation-section');
+
+ // back to index
+ await page.locator('header [href="/"]').click();
+ await expect(page).toHaveURL('/');
+ let link = page.locator('footer').getByRole('link', { name: 'Support', exact: true });
+ await expect(link).toBeVisible();
+ await expect(link).toHaveAttribute('href', '/support');
+
+ // goto support
+ await link.click();
+ await expect(page).toHaveURL('/support');
+ section = page.getByTestId('support-main-content').locator('section');
+ await expect(section).toHaveCount(1);
+ await expect(section).toHaveAttribute('data-test-id', 'inquire-list-section');
+ await page.getByTestId('link-crate-violation').click();
+ await expect(page).toHaveURL('/support?inquire=crate-violation');
+ });
+
+ test('LinkTo support must overwirte query', async ({ page, ember }) => {
+ await ember.addHook(async owner => {
+ const Service = require('@ember/service').default;
+ // query params of LinkTo support's in footer will not be cleared
+ class MockService extends Service {
+ paramsFor() {
+ return {};
+ }
+ }
+ owner.register('service:pristine-query', MockService);
+ });
+ await page.goto('/support?inquire=crate-violation');
+ await expect(page).toHaveURL('/support?inquire=crate-violation');
+ let section = page.getByTestId('support-main-content').locator('section');
+ await expect(section).toHaveCount(1);
+ await expect(section).toHaveAttribute('data-test-id', 'crate-violation-section');
+ // without overwriting, link in footer will contain the query params in support route
+ let link = page.locator('footer').getByRole('link', { name: 'Support', exact: true });
+ await expect(link).not.toHaveAttribute('href', '/support');
+ await expect(link).toHaveAttribute('href', '/support?inquire=crate-violation');
+
+ // back to index
+ await page.locator('header [href="/"]').click();
+ await expect(page).toHaveURL('/');
+ link = page.locator('footer').getByRole('link', { name: 'Support', exact: true });
+ await expect(link).toBeVisible();
+ await expect(link).toHaveAttribute('href', '/support');
+ });
+});
diff --git a/playwright.config.ts b/playwright.config.ts
index 12189a6d054..07416a80bbd 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -30,6 +30,9 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
+
+ /* Set a custom test id that is also compatible with `ember-test-selectors` */
+ testIdAttribute: 'data-test-id',
},
/* Configure projects for major browsers */
diff --git a/tests/acceptance/support-test.js b/tests/acceptance/support-test.js
new file mode 100644
index 00000000000..28a95f99302
--- /dev/null
+++ b/tests/acceptance/support-test.js
@@ -0,0 +1,310 @@
+import { click, currentURL, fillIn, findAll } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+
+import percySnapshot from '@percy/ember';
+import a11yAudit from 'ember-a11y-testing/test-support/audit';
+import window from 'ember-window-mock';
+import { setupWindowMock } from 'ember-window-mock/test-support';
+
+import { setupApplicationTest } from 'crates-io/tests/helpers';
+
+import axeConfig from '../axe-config';
+import { visit } from '../helpers/visit-ignoring-abort';
+
+module('Acceptance | support', function (hooks) {
+ setupApplicationTest(hooks);
+
+ test('shows an inquire list', async function (assert) {
+ await visit('/support');
+ assert.strictEqual(currentURL(), '/support');
+
+ assert.dom('[data-test-id="support-main-content"] section').exists({ count: 1 });
+ assert.dom('[data-test-id="inquire-list-section"]').exists();
+ assert.dom('[data-test-id="inquire-list"]').exists();
+ const listitem = findAll('[data-test-id="inquire-list"] li');
+ assert.deepEqual(
+ listitem.map(item => item.textContent.trim()),
+ ['Report a crate that violates policies'].concat(['For all other cases']),
+ );
+
+ await percySnapshot(assert);
+ await a11yAudit(axeConfig);
+ });
+
+ test('shows an inquire list if given inquire is not supported', async function (assert) {
+ await visit('/support?inquire=not-supported-inquire');
+ assert.strictEqual(currentURL(), '/support?inquire=not-supported-inquire');
+
+ assert.dom('[data-test-id="support-main-content"] section').exists({ count: 1 });
+ assert.dom('[data-test-id="inquire-list-section"]').exists();
+ assert.dom('[data-test-id="inquire-list"]').exists();
+ const listitem = findAll('[data-test-id="inquire-list"] li');
+ assert.deepEqual(
+ listitem.map(item => item.textContent.trim()),
+ ['Report a crate that violates policies'].concat(['For all other cases']),
+ );
+ });
+
+ module('reporting a crate from support page', function () {
+ setupWindowMock(hooks);
+
+ async function prepare(context, assert) {
+ let server = context.server;
+ let crate = server.create('crate', { name: 'nanomsg' });
+ server.create('version', { crate, num: '0.6.0' });
+
+ window.open = (url, target, features) => {
+ window.openKwargs = { url, target, features };
+ return { document: { write() {}, close() {} }, close() {} };
+ };
+
+ await visit('/support');
+ await click('[data-test-id="link-crate-violation"]');
+ assert.strictEqual(currentURL(), '/support?inquire=crate-violation');
+ }
+
+ test('show a report form', async function (assert) {
+ await prepare(this, assert);
+
+ assert.dom('[data-test-id="support-main-content"] section').exists({ count: 1 });
+ assert.dom('[data-test-id="crate-violation-section"]').exists();
+ assert.dom('[data-test-id="fieldset-crate"]').exists();
+ assert.dom('[data-test-id="fieldset-reasons"]').exists();
+ assert.dom('[data-test-id="fieldset-detail"]').exists();
+ assert.dom('[data-test-id="report-button"]').hasText('Report');
+
+ await percySnapshot(assert);
+ await a11yAudit(axeConfig);
+ });
+
+ test('empty form should shows errors', async function (assert) {
+ await prepare(this, assert);
+ await click('[data-test-id="report-button"]');
+
+ assert.dom('[data-test-id="crate-invalid"]').exists();
+ assert.dom('[data-test-id="reasons-invalid"]').exists();
+ assert.dom('[data-test-id="detail-invalid"]').doesNotExist();
+
+ assert.strictEqual(window.openKwargs, undefined);
+ });
+
+ test('empty crate should shows errors', async function (assert) {
+ await prepare(this, assert);
+ assert.dom('[data-test-id="crate-input"]').hasValue('');
+ await click('[data-test-id="report-button"]');
+
+ assert.dom('[data-test-id="crate-invalid"]').exists();
+ assert.dom('[data-test-id="reasons-invalid"]').exists();
+ assert.dom('[data-test-id="detail-invalid"]').doesNotExist();
+
+ assert.strictEqual(window.openKwargs, undefined);
+ });
+
+ test('other reason selected without given detail shows an error', async function (assert) {
+ await prepare(this, assert);
+ await fillIn('[data-test-id="crate-input"]', 'nanomsg');
+ assert.dom('[data-test-id="crate-input"]').hasValue('nanomsg');
+
+ await click('[data-test-id="spam-checkbox"]');
+ assert.dom('[data-test-id="spam-checkbox"]').isChecked();
+ await click('[data-test-id="other-checkbox"]');
+ assert.dom('[data-test-id="other-checkbox"]').isChecked();
+ assert.dom('[data-test-id="detail-input"]').hasValue('');
+ await click('[data-test-id="report-button"]');
+
+ assert.dom('[data-test-id="crate-invalid"]').doesNotExist();
+ assert.dom('[data-test-id="reasons-invalid"]').doesNotExist();
+ assert.dom('[data-test-id="detail-invalid"]').exists();
+
+ assert.strictEqual(window.openKwargs, undefined);
+ });
+
+ test('valid form without detail', async function (assert) {
+ await prepare(this, assert);
+ await fillIn('[data-test-id="crate-input"]', 'nanomsg');
+ assert.dom('[data-test-id="crate-input"]').hasValue('nanomsg');
+
+ await click('[data-test-id="spam-checkbox"]');
+ assert.dom('[data-test-id="spam-checkbox"]').isChecked();
+ assert.dom('[data-test-id="detail-input"]').hasValue('');
+ await click('[data-test-id="report-button"]');
+
+ assert.dom('[data-test-id="crate-invalid"]').doesNotExist();
+ assert.dom('[data-test-id="reasons-invalid"]').doesNotExist();
+ assert.dom('[data-test-id="detail-invalid"]').doesNotExist();
+
+ let body = `I'm reporting the https://crates.io/crates/nanomsg crate because:
+
+- [x] it contains spam
+- [ ] it is name-squatting (reserving a crate name without content)
+- [ ] it is abusive or otherwise harmful
+- [ ] it contains a vulnerability (please try to contact the crate author first)
+- [ ] it is violating the usage policy in some other way (please specify below)
+
+Additional details:
+
+
+`;
+ let subject = `The "nanomsg" crate`;
+ let address = 'help@crates.io';
+ let mailto = `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ assert.true(!!window.openKwargs);
+ assert.strictEqual(window.openKwargs.url, mailto);
+ assert.strictEqual(window.openKwargs.target, '_self');
+ });
+
+ test('valid form with required detail', async function (assert) {
+ await prepare(this, assert);
+ await fillIn('[data-test-id="crate-input"]', 'nanomsg');
+ assert.dom('[data-test-id="crate-input"]').hasValue('nanomsg');
+
+ await click('[data-test-id="spam-checkbox"]');
+ assert.dom('[data-test-id="spam-checkbox"]').isChecked();
+ await click('[data-test-id="other-checkbox"]');
+ assert.dom('[data-test-id="other-checkbox"]').isChecked();
+ await fillIn('[data-test-id="detail-input"]', 'test detail');
+ assert.dom('[data-test-id="detail-input"]').hasValue('test detail');
+ await click('[data-test-id="report-button"]');
+
+ assert.dom('[data-test-id="crate-invalid"]').doesNotExist();
+ assert.dom('[data-test-id="reasons-invalid"]').doesNotExist();
+ assert.dom('[data-test-id="detail-invalid"]').doesNotExist();
+
+ let body = `I'm reporting the https://crates.io/crates/nanomsg crate because:
+
+- [x] it contains spam
+- [ ] it is name-squatting (reserving a crate name without content)
+- [ ] it is abusive or otherwise harmful
+- [ ] it contains a vulnerability (please try to contact the crate author first)
+- [x] it is violating the usage policy in some other way (please specify below)
+
+Additional details:
+
+test detail
+`;
+ let subject = `The "nanomsg" crate`;
+ let address = 'help@crates.io';
+ let mailto = `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ assert.true(!!window.openKwargs);
+ assert.strictEqual(window.openKwargs.url, mailto);
+ assert.strictEqual(window.openKwargs.target, '_self');
+ });
+ });
+
+ module('reporting a crate from crate page', function () {
+ setupWindowMock(hooks);
+
+ async function prepare(context, assert) {
+ let server = context.server;
+ let crate = server.create('crate', { name: 'nanomsg' });
+ server.create('version', { crate, num: '0.6.0' });
+
+ window.open = (url, target, features) => {
+ window.openKwargs = { url, target, features };
+ return { document: { write() {}, close() {} }, close() {} };
+ };
+
+ await visit('/crates/nanomsg');
+ await click('[data-test-id="link-crate-report"]');
+ assert.strictEqual(currentURL(), '/support?crate=nanomsg&inquire=crate-violation');
+ assert.dom('[data-test-id="crate-input"]').hasValue('nanomsg');
+ }
+
+ test('empty crate should shows errors', async function (assert) {
+ await prepare(this, assert);
+ await fillIn('[data-test-id="crate-input"]', '');
+ assert.dom('[data-test-id="crate-input"]').hasValue('');
+ await click('[data-test-id="report-button"]');
+
+ assert.dom('[data-test-id="crate-invalid"]').exists();
+ assert.dom('[data-test-id="reasons-invalid"]').exists();
+ assert.dom('[data-test-id="detail-invalid"]').doesNotExist();
+
+ assert.strictEqual(window.openKwargs, undefined);
+ });
+
+ test('other reason selected without given detail shows an error', async function (assert) {
+ await prepare(this, assert);
+
+ await click('[data-test-id="spam-checkbox"]');
+ assert.dom('[data-test-id="spam-checkbox"]').isChecked();
+ await click('[data-test-id="other-checkbox"]');
+ assert.dom('[data-test-id="other-checkbox"]').isChecked();
+ assert.dom('[data-test-id="detail-input"]').hasValue('');
+ await click('[data-test-id="report-button"]');
+
+ assert.dom('[data-test-id="crate-invalid"]').doesNotExist();
+ assert.dom('[data-test-id="reasons-invalid"]').doesNotExist();
+ assert.dom('[data-test-id="detail-invalid"]').exists();
+
+ assert.strictEqual(window.openKwargs, undefined);
+ });
+
+ test('valid form without detail', async function (assert) {
+ await prepare(this, assert);
+
+ await click('[data-test-id="spam-checkbox"]');
+ assert.dom('[data-test-id="spam-checkbox"]').isChecked();
+ assert.dom('[data-test-id="detail-input"]').hasValue('');
+ await click('[data-test-id="report-button"]');
+
+ assert.dom('[data-test-id="crate-invalid"]').doesNotExist();
+ assert.dom('[data-test-id="reasons-invalid"]').doesNotExist();
+ assert.dom('[data-test-id="detail-invalid"]').doesNotExist();
+
+ let body = `I'm reporting the https://crates.io/crates/nanomsg crate because:
+
+- [x] it contains spam
+- [ ] it is name-squatting (reserving a crate name without content)
+- [ ] it is abusive or otherwise harmful
+- [ ] it contains a vulnerability (please try to contact the crate author first)
+- [ ] it is violating the usage policy in some other way (please specify below)
+
+Additional details:
+
+
+`;
+ let subject = `The "nanomsg" crate`;
+ let address = 'help@crates.io';
+ let mailto = `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ assert.true(!!window.openKwargs);
+ assert.strictEqual(window.openKwargs.url, mailto);
+ assert.strictEqual(window.openKwargs.target, '_self');
+ });
+
+ test('valid form with required detail', async function (assert) {
+ await prepare(this, assert);
+
+ await click('[data-test-id="spam-checkbox"]');
+ assert.dom('[data-test-id="spam-checkbox"]').isChecked();
+ await click('[data-test-id="other-checkbox"]');
+ assert.dom('[data-test-id="other-checkbox"]').isChecked();
+ await fillIn('[data-test-id="detail-input"]', 'test detail');
+ assert.dom('[data-test-id="detail-input"]').hasValue('test detail');
+ await click('[data-test-id="report-button"]');
+
+ assert.dom('[data-test-id="crate-invalid"]').doesNotExist();
+ assert.dom('[data-test-id="reasons-invalid"]').doesNotExist();
+ assert.dom('[data-test-id="detail-invalid"]').doesNotExist();
+
+ let body = `I'm reporting the https://crates.io/crates/nanomsg crate because:
+
+- [x] it contains spam
+- [ ] it is name-squatting (reserving a crate name without content)
+- [ ] it is abusive or otherwise harmful
+- [ ] it contains a vulnerability (please try to contact the crate author first)
+- [x] it is violating the usage policy in some other way (please specify below)
+
+Additional details:
+
+test detail
+`;
+ let subject = `The "nanomsg" crate`;
+ let address = 'help@crates.io';
+ let mailto = `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ assert.true(!!window.openKwargs);
+ assert.strictEqual(window.openKwargs.url, mailto);
+ assert.strictEqual(window.openKwargs.target, '_self');
+ });
+ });
+});
diff --git a/tests/routes/support-test.js b/tests/routes/support-test.js
new file mode 100644
index 00000000000..dc930d32340
--- /dev/null
+++ b/tests/routes/support-test.js
@@ -0,0 +1,91 @@
+import { click, currentURL } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+
+import Service from '@ember/service';
+
+import { setupApplicationTest } from 'crates-io/tests/helpers';
+
+import { visit } from '../helpers/visit-ignoring-abort';
+
+module('Route | support', function (hooks) {
+ setupApplicationTest(hooks);
+
+ test('should not retain query params when exiting and then returning', async function (assert) {
+ await visit('/support?inquire=crate-violation');
+ assert.strictEqual(currentURL(), '/support?inquire=crate-violation');
+ assert
+ .dom('[data-test-id="support-main-content"] section')
+ .exists({ count: 1 })
+ .hasAttribute('data-test-id', 'crate-violation-section');
+
+ // back to index
+ await click('header [href="/"]');
+ assert.strictEqual(currentURL(), '/');
+ assert.dom('footer [href="/support"]').exists();
+
+ // goto support
+ await click('footer [href="/support"]');
+ assert.strictEqual(currentURL(), '/support');
+ assert
+ .dom('[data-test-id="support-main-content"] section')
+ .exists({ count: 1 })
+ .hasAttribute('data-test-id', 'inquire-list-section');
+ await click('[data-test-id="link-crate-violation"]');
+ assert.strictEqual(currentURL(), '/support?inquire=crate-violation');
+ });
+
+ test('LinkTo support must overwite query', async function (assert) {
+ // query params of LinkTo support's in footer will not be cleared
+ class MockService extends Service {
+ paramsFor() {
+ return {};
+ }
+ }
+ this.owner.register('service:pristine-query', MockService);
+
+ await visit('/support?inquire=crate-violation');
+ assert.strictEqual(currentURL(), '/support?inquire=crate-violation');
+ assert
+ .dom('[data-test-id="support-main-content"] section')
+ .exists({ count: 1 })
+ .hasAttribute('data-test-id', 'crate-violation-section');
+ // without overwriting, link in footer will contain the query params in support route
+ assert.dom('footer [href^="/support"]').doesNotMatchSelector('[href="/support"]');
+ assert.dom('footer [href^="/support"]').hasAttribute('href', '/support?inquire=crate-violation');
+
+ // back to index
+ await click('header [href="/"]');
+ assert.strictEqual(currentURL(), '/');
+ assert.dom('footer [href^="/support"]').hasAttribute('href', '/support');
+ });
+
+ test('must reset query when existing', async function (assert) {
+ const route = this.owner.lookup('route:support');
+ const originResetController = route.resetController;
+ // query params of LinkTo support's in footer will not be cleared
+ class MockService extends Service {
+ paramsFor() {
+ return {};
+ }
+ }
+ this.owner.register('service:pristine-query', MockService);
+ // exiting support will not reset query
+ route.resetController = () => {};
+
+ await visit('/support?inquire=crate-violation');
+ assert.strictEqual(currentURL(), '/support?inquire=crate-violation');
+ assert
+ .dom('[data-test-id="support-main-content"] section')
+ .exists({ count: 1 })
+ .hasAttribute('data-test-id', 'crate-violation-section');
+
+ // back to index
+ await click('header [href="/"]');
+ assert.strictEqual(currentURL(), '/');
+ // without resetController to reset, link in footer will contain the query params in other route
+ assert.dom('footer [href^="/support"]').doesNotMatchSelector('[href="/support"]');
+ assert.dom('footer [href^="/support"]').hasAttribute('href', '/support?inquire=crate-violation');
+
+ route.resetController = originResetController;
+ });
+});