mirror of
https://github.com/wassname/talk.git
synced 2026-07-02 17:06:34 +08:00
Merge branch 'master' of https://github.com/coralproject/talk into reportedComments
Updating
This commit is contained in:
@@ -4,8 +4,12 @@ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
|
||||
either express or implied.
|
||||
|
||||
See the License for the specific language governing permissions and limitations under the License.
|
||||
See the License for the specific language governing permissions
|
||||
and limitations under the License.
|
||||
|
||||
@@ -1,46 +1,37 @@
|
||||
# Talk
|
||||
# Talk · [](https://circleci.com/gh/coralproject/talk) · [](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8) · [](CONTRIBUTING.md#pull-requests)
|
||||
|
||||
[](https://circleci.com/gh/coralproject/talk)
|
||||
[](https://dashboard.heroku.com/new?template=https%3A%2F%2Fgithub.com%2Fcoralproject%2Ftalk&env[TALK_FACEBOOK_APP_ID]=ignore&env[TALK_FACEBOOK_APP_SECRET]=ignore)
|
||||
[](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8)
|
||||
|
||||
Online comments are broken. Our open-source Talk tool rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here](https://coralproject.net/products/talk.html).
|
||||
Online comments are broken. Our open-source commenting platform, Talk, rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here](https://coralproject.net/products/talk.html).
|
||||
|
||||
Built with <3 by The Coral Project & Mozilla.
|
||||
|
||||
## Getting Started
|
||||
## Try Talk!
|
||||
|
||||
Check out our Docs: https://coralproject.github.io/talk/
|
||||
You're just one click away from trying Talk - all you need is a Heroku account and a few minutes of your time.
|
||||
|
||||
[](https://dashboard.heroku.com/new?template=https%3A%2F%2Fgithub.com%2Fcoralproject%2Ftalk&env[TALK_FACEBOOK_APP_ID]=ignore&env[TALK_FACEBOOK_APP_SECRET]=ignore)
|
||||
|
||||
## Technical Documentation
|
||||
|
||||
From getting up and running, to advanced configuration, to how to scale Talk, our [Talk Technical Docs](https://coralproject.github.io/talk/) have everything you need to know.
|
||||
|
||||
## Product Guide
|
||||
|
||||
Learn more about Talk, including a deep dive into features for commenters and moderators, and FAQs in our [Talk Product Guide](https://coralproject.github.io/talk/how-talk-works).
|
||||
|
||||
## Relevant Links
|
||||
|
||||
- Blog: https://blog.coralproject.net/
|
||||
- Community Forums: https://community.coralproject.net/
|
||||
- Community Guides for Journalism: https://guides.coralproject.net/
|
||||
- Project: https://coralproject.net/
|
||||
- Roadmap: https://www.pivotaltracker.com/n/projects/1863625
|
||||
- [Our Blog](https://blog.coralproject.net/)
|
||||
- [Community Forums](https://community.coralproject.net/)
|
||||
- [Community Guides for Journalism](https://guides.coralproject.net/)
|
||||
- [More About Us](https://coralproject.net/)
|
||||
- [Talk Roadmap](https://www.pivotaltracker.com/n/projects/1863625)
|
||||
|
||||
## End-to-End Testing
|
||||
|
||||
Talk uses [Nightwatch](http://nightwatchjs.org/) to write e2e tests. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at Browserstack.
|
||||
Talk uses [Nightwatch](https://nightwatchjs.org/) as our e2e testing framework. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at [Browserstack](https://browserstack.com).
|
||||
|
||||
|
||||

|
||||
[](https://browserstack.com)
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2017 Mozilla Foundation
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
|
||||
either express or implied.
|
||||
|
||||
See the License for the specific language governing permissions
|
||||
and limitations under the License.
|
||||
Talk is released under the [Apache License, v2.0](/LICENSE).
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ test:
|
||||
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml MOCHA_REPORTER=mocha-junit-reporter yarn test
|
||||
# Check dependancies using nsp.
|
||||
- nsp check
|
||||
# - yarn e2e-ci
|
||||
- yarn e2e-ci
|
||||
|
||||
deployment:
|
||||
release:
|
||||
|
||||
@@ -26,18 +26,29 @@ const nightwatch_config = {
|
||||
|
||||
// Disable this, as it makes bs slow and brittle.
|
||||
'browserstack.networkLogs': false,
|
||||
}
|
||||
'browserstack.resolution': '1600x1200',
|
||||
},
|
||||
screenshots : {
|
||||
enabled: true,
|
||||
on_failure: true,
|
||||
on_error: true,
|
||||
path: process.env.REPORTS_FOLDER || './test/e2e/reports',
|
||||
},
|
||||
},
|
||||
chrome: {
|
||||
desiredCapabilities: {
|
||||
browser: 'chrome',
|
||||
browser_version: '62',
|
||||
os: 'Windows',
|
||||
os_version: '10',
|
||||
},
|
||||
},
|
||||
firefox: {
|
||||
desiredCapabilities: {
|
||||
browser: 'firefox',
|
||||
browser_version: '56',
|
||||
os: 'Windows',
|
||||
os_version: '10',
|
||||
},
|
||||
},
|
||||
safari: {
|
||||
@@ -52,14 +63,21 @@ const nightwatch_config = {
|
||||
desiredCapabilities: {
|
||||
browser: 'internet explorer',
|
||||
os: 'Windows',
|
||||
os_version: '8.1',
|
||||
os_version: '10',
|
||||
browser_version: '11',
|
||||
|
||||
// The x64 bit IEDriver that is used by IE 11 has a known issue with sendKeys where
|
||||
// it may enter incorrect keys (shift + key).
|
||||
// This adds a delay for each character as temporary fix as advised from the browserstack support.
|
||||
'browserstack.customSendKeys': 800,
|
||||
},
|
||||
},
|
||||
edge: {
|
||||
desiredCapabilities: {
|
||||
browser: 'edge',
|
||||
browser_version: '15',
|
||||
os: 'Windows',
|
||||
os_version: '10',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+4
-4
@@ -9,13 +9,13 @@ module.exports = {
|
||||
globals_path: './test/e2e/globals',
|
||||
selenium: {
|
||||
start_process: true,
|
||||
server_path: 'node_modules/selenium-standalone/.selenium/selenium-server/3.5.3-server.jar',
|
||||
server_path: 'node_modules/selenium-standalone/.selenium/selenium-server/3.6.0-server.jar',
|
||||
log_path: './test/e2e/',
|
||||
host: '127.0.0.1',
|
||||
port: 6666,
|
||||
cli_args: {
|
||||
'webdriver.chrome.driver': 'node_modules/selenium-standalone/.selenium/chromedriver/2.32-x64-chromedriver',
|
||||
'webdriver.gecko.driver': 'node_modules/selenium-standalone/.selenium/geckodriver/0.18.0-x64-geckodriver',
|
||||
'webdriver.chrome.driver': 'node_modules/selenium-standalone/.selenium/chromedriver/2.33-x64-chromedriver',
|
||||
'webdriver.gecko.driver': 'node_modules/selenium-standalone/.selenium/geckodriver/0.19.0-x64-geckodriver',
|
||||
}
|
||||
},
|
||||
test_settings: {
|
||||
@@ -43,7 +43,7 @@ module.exports = {
|
||||
'chrome-headless': {
|
||||
desiredCapabilities: {
|
||||
chromeOptions : {
|
||||
args: ['--headless', '--disable-gpu', 'window-size=1280,800'],
|
||||
args: ['--headless', '--disable-gpu', 'window-size=1600,1200'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@
|
||||
"redux": "^3.6.0",
|
||||
"redux-thunk": "^2.1.0",
|
||||
"resolve": "^1.4.0",
|
||||
"selenium-standalone": "6.9.0",
|
||||
"selenium-standalone": "^6.11.0",
|
||||
"semver": "^5.4.1",
|
||||
"simplemde": "^1.11.2",
|
||||
"smoothscroll-polyfill": "^0.3.5",
|
||||
|
||||
+66
-11
@@ -1,32 +1,87 @@
|
||||
#!/bin/bash
|
||||
|
||||
CIRCLE_TEST_REPORTS=${CIRCLE_TEST_REPORTS:-./test/e2e/reports}
|
||||
CIRCLE_BRANCH=${CIRCLE_BRANCH:-master}
|
||||
|
||||
# Amount of retries before failure.
|
||||
E2E_MAX_RETRIES=${E2E_MAX_RETRIES:-1}
|
||||
|
||||
# Amount of seconds between tests.
|
||||
E2E_SLEEP_BETWEEN_TESTS=${E2E_SLEEP_BETWEEN_TESTS:-1}
|
||||
|
||||
# Safari >= 8 has issues connecting to browserstack-local. Safari < 8 is too old.
|
||||
BROWSERS="chrome firefox ie edge" #safari
|
||||
|
||||
if [[ "${CIRCLE_BRANCH}" == "master" ]]; then
|
||||
|
||||
# List of failed browsers.
|
||||
failedBrowsers=
|
||||
|
||||
# List of succeeded browsers.
|
||||
succeededBrowsers=
|
||||
|
||||
exitCode=0
|
||||
|
||||
browserstack() {
|
||||
REPORTS_FOLDER="$CIRCLE_TEST_REPORTS/$1" yarn e2e-browserstack -- --env "$1"
|
||||
|
||||
# Current number of tries.
|
||||
try=${2:-0}
|
||||
|
||||
echo "-- Start e2e for $1 #$try --"
|
||||
|
||||
REPORTS_FOLDER="$CIRCLE_TEST_REPORTS/$1" yarn e2e-browserstack --env "$1"
|
||||
|
||||
# Determine exit code.
|
||||
result=$?
|
||||
if [ "$result" -gt "0" ]
|
||||
then
|
||||
if [ "$result" -ne "0" ]; then
|
||||
echo "-- Failed e2e for $1 #$try --"
|
||||
|
||||
# Try again until E2E_MAX_RETRIES is reached.
|
||||
if [ "$try" -lt "$E2E_MAX_RETRIES" ]; then
|
||||
let try=try+1
|
||||
|
||||
# Sleep a bit to let browserstack-local close properly.
|
||||
sleep "$E2E_SLEEP_BETWEEN_TESTS"
|
||||
|
||||
browserstack "$1" "$try"
|
||||
return
|
||||
fi
|
||||
|
||||
# Failed, add to list of failed browsers.
|
||||
failedBrowsers="$failedBrowsers $1"
|
||||
|
||||
# Remember exit code.
|
||||
exitCode=$result
|
||||
else
|
||||
echo "-- Success e2e for $1 #$try --"
|
||||
|
||||
# Succeeded, add to list of succeeded browsers.
|
||||
succeededBrowsers="$succeededBrowsers $1"
|
||||
eval "browser_${1}_succeeded_at=$try"
|
||||
fi
|
||||
|
||||
# Sleep a bit to let browserstack-local to close properly.
|
||||
sleep 2
|
||||
# Sleep a bit to let browserstack-local close properly.
|
||||
sleep "$E2E_SLEEP_BETWEEN_TESTS"
|
||||
}
|
||||
|
||||
# Test using browserstack.
|
||||
browserstack chrome
|
||||
browserstack firefox
|
||||
browserstack ie
|
||||
browserstack edge
|
||||
for browser in $BROWSERS
|
||||
do
|
||||
browserstack "$browser"
|
||||
done
|
||||
|
||||
# Safari >= 8 has issues connecting to browserstack-local. Safari < 8 is too old.
|
||||
# browserstack safari
|
||||
|
||||
# Print information about succeeded browsers.
|
||||
for x in $succeededBrowsers
|
||||
do
|
||||
echo "Succeeded $x at try #$(eval "echo \$browser_${x}_succeeded_at")"
|
||||
done
|
||||
|
||||
# Print information about failed browsers.
|
||||
for x in $failedBrowsers
|
||||
do
|
||||
echo "Failed $x"
|
||||
done
|
||||
exit $exitCode
|
||||
else
|
||||
# When browserstack is not available test locally using chrome headless.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<%= body.replace(/\n/g, '<br />') %>
|
||||
@@ -0,0 +1 @@
|
||||
<%= body %>
|
||||
+20
-1
@@ -398,7 +398,7 @@ module.exports = class UsersService {
|
||||
// TODO: current updating status behavior is weird.
|
||||
// once a user has been `APPROVED` its status cannot be
|
||||
// changed anymore.
|
||||
return UserModel.findOneAndUpdate({
|
||||
const user = await UserModel.findOneAndUpdate({
|
||||
id,
|
||||
status: {
|
||||
$ne: 'APPROVED'
|
||||
@@ -410,6 +410,25 @@ module.exports = class UsersService {
|
||||
}, {
|
||||
new: true,
|
||||
});
|
||||
|
||||
if (status === 'BANNED') {
|
||||
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
|
||||
if (localProfile) {
|
||||
const options =
|
||||
{
|
||||
template: 'banned', // needed to know which template to render!
|
||||
locals: { // specifies the template locals.
|
||||
body: 'In accordance with The Coral Project’s community guidelines, your account has been banned. You are now longer allowed to comment, flag or engage with our community.'
|
||||
},
|
||||
subject: 'Your account has been banned',
|
||||
to: localProfile.id // This only works if the user has registered via e-mail.
|
||||
// We may want a standard way to access a user's e-mail address in the future
|
||||
};
|
||||
await MailerService.sendSimple(options);
|
||||
}
|
||||
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,11 @@ module.exports = {
|
||||
},
|
||||
getEmbedSection: function() {
|
||||
this.waitForElementVisible('@iframe');
|
||||
|
||||
// Pause a bit to let iframe initialize in the hope that it'll
|
||||
// fix https://www.browserstack.com/automate/builds/96419cf46e3d6376a36ae6d3f90934112df1ed91/sessions/224f1a1566c1c8c7859e2e76ece51862200f0173#automate_button
|
||||
this.api.pause(200);
|
||||
|
||||
this.api.frame(iframeId);
|
||||
this.expect.section('@embed').to.be.present;
|
||||
return this.section.embed;
|
||||
@@ -23,11 +28,13 @@ module.exports = {
|
||||
embed: {
|
||||
commands: [{
|
||||
getProfileSection: function() {
|
||||
this.waitForElementVisible('@profileTabButton');
|
||||
this.click('@profileTabButton');
|
||||
this.expect.section('@profile').to.be.present;
|
||||
return this.section.profile;
|
||||
},
|
||||
getCommentsSection: function() {
|
||||
this.waitForElementVisible('@commentsTabButton');
|
||||
this.click('@commentsTabButton');
|
||||
this.expect.section('@comments').to.be.present;
|
||||
return this.section.comments;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
module.exports = {
|
||||
'@tags': ['admin', 'login'],
|
||||
beforeEach: (client) => {
|
||||
|
||||
|
||||
client.resizeWindow(1024, 800);
|
||||
},
|
||||
},
|
||||
'Admin logs in': (client) => {
|
||||
const adminPage = client.page.admin();
|
||||
const {testData: {admin}} = client.globals;
|
||||
@@ -17,8 +17,6 @@ module.exports = {
|
||||
.waitForElementVisible('@signInButton')
|
||||
.click('@signInButton');
|
||||
|
||||
client.pause(3000);
|
||||
|
||||
adminPage
|
||||
.waitForElementVisible('@moderationContainer');
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const SortedWindowHandler = require('../utils/SortedWindowHandler');
|
||||
|
||||
module.exports = {
|
||||
'@tags': ['embedStream', 'login'],
|
||||
'creates a new asset': (client) => {
|
||||
@@ -18,16 +20,19 @@ module.exports = {
|
||||
.navigate()
|
||||
.getEmbedSection();
|
||||
|
||||
const windowHandler = new SortedWindowHandler(client);
|
||||
|
||||
embed
|
||||
.waitForElementVisible('@signInButton')
|
||||
.click('@signInButton');
|
||||
|
||||
client.pause(3000);
|
||||
// Wait for window to be created
|
||||
// https://www.browserstack.com/automate/builds/1ceccf4efb4683b7feb890f45a32b5922b40ed3f/sessions/17b1a79682bef2498cb0be86eac317a08c976b0a#automate_button
|
||||
client.pause(200);
|
||||
|
||||
// Focusing on the Login PopUp
|
||||
client.windowHandles((result) => {
|
||||
const handle = result.value[1];
|
||||
client.switchWindow(handle);
|
||||
windowHandler.windowHandles((handles) => {
|
||||
client.switchWindow(handles[1]);
|
||||
});
|
||||
|
||||
const login = client.page.login();
|
||||
@@ -45,10 +50,19 @@ module.exports = {
|
||||
.waitForElementVisible('@loginButton')
|
||||
.click('@loginButton');
|
||||
|
||||
// Give a tiny bit of time to let popup close.
|
||||
client.pause(50);
|
||||
|
||||
if (client.capabilities.browserName === 'MicrosoftEdge') {
|
||||
|
||||
// More time for edge.
|
||||
// https://www.browserstack.com/automate/builds/1ceccf4efb4683b7feb890f45a32b5922b40ed3f/sessions/7393dbfda8387e43b6d5851f359b0c07db414973
|
||||
client.pause(1000);
|
||||
}
|
||||
|
||||
// Focusing on the Embed Window
|
||||
client.windowHandles((result) => {
|
||||
const handle = result.value[0];
|
||||
client.switchWindow(handle);
|
||||
windowHandler.windowHandles((handles) => {
|
||||
client.switchWindow(handles[0]);
|
||||
});
|
||||
},
|
||||
'user posts a comment': (client) => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
|
||||
/**
|
||||
* SortedWindowHandler assists in making e2e tests more robust by returning
|
||||
* deterministic window handles. An instance must be created before new windows
|
||||
* are created and windowHandles must be called each time a window was created or
|
||||
* closed.
|
||||
*/
|
||||
class SortedWindowHandler {
|
||||
|
||||
/**
|
||||
* Constructor, must be called before new windows were created.
|
||||
*/
|
||||
constructor(client) {
|
||||
this.client = client;
|
||||
this.client.windowHandles((result) => {
|
||||
this.handles = result.value;
|
||||
if (this.handles.length > 2) {
|
||||
throw new Error('SortedWindowHandler must be created before new windows were created.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* windowHandles will call given `callback` with an array of window handles.
|
||||
*/
|
||||
windowHandles(callback) {
|
||||
this.client.windowHandles((result) => {
|
||||
this.handles = this.handles.filter((handle) => result.value.includes(handle));
|
||||
const remaining = result.value.filter((handle) => !this.handles.includes(handle));
|
||||
if (remaining.length === 1) {
|
||||
this.handles.push(remaining[0]);
|
||||
}
|
||||
if (remaining.length > 1) {
|
||||
throw new Error('Cannot detect new window handle, because more than one windows was created.');
|
||||
}
|
||||
callback(this.handles);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SortedWindowHandler;
|
||||
@@ -1,8 +1,11 @@
|
||||
const UsersService = require('../../../services/users');
|
||||
const SettingsService = require('../../../services/settings');
|
||||
const MailerService = require('../../../services/mailer');
|
||||
|
||||
const chai = require('chai');
|
||||
chai.use(require('chai-as-promised'));
|
||||
const sinon = require('sinon');
|
||||
chai.use(require('sinon-chai'));
|
||||
const expect = chai.expect;
|
||||
|
||||
describe('services.UsersService', () => {
|
||||
@@ -15,7 +18,7 @@ describe('services.UsersService', () => {
|
||||
mockUsers = await UsersService.createLocalUsers([{
|
||||
email: 'stampi@gmail.com',
|
||||
username: 'Stampi',
|
||||
password: '1Coral!-'
|
||||
password: '1Coral!-',
|
||||
}, {
|
||||
email: 'sockmonster@gmail.com',
|
||||
username: 'Sockmonster',
|
||||
@@ -25,6 +28,12 @@ describe('services.UsersService', () => {
|
||||
username: 'Marvel',
|
||||
password: '3Coral!3'
|
||||
}]);
|
||||
|
||||
sinon.spy(MailerService, 'sendSimple');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
MailerService.sendSimple.restore();
|
||||
});
|
||||
|
||||
describe('#findById()', () => {
|
||||
@@ -149,7 +158,11 @@ describe('services.UsersService', () => {
|
||||
.then(() => UsersService.findById(mockUsers[0].id))
|
||||
.then((user) => {
|
||||
expect(user).to.have.property('status', 'ACTIVE');
|
||||
})
|
||||
.then(() => {
|
||||
expect(MailerService.sendSimple).to.not.have.been.called;
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,6 +201,12 @@ describe('services.UsersService', () => {
|
||||
.then(() => UsersService.findById(mockUsers[0].id))
|
||||
.then((user) => {
|
||||
expect(user).to.have.property('status', 'BANNED');
|
||||
})
|
||||
.then(() => {
|
||||
expect(MailerService.sendSimple).to.have.been.calledWithMatch({
|
||||
template: 'banned',
|
||||
to: mockUsers[0].profiles[0].id
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7953,9 +7953,9 @@ select@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d"
|
||||
|
||||
selenium-standalone@6.9.0:
|
||||
version "6.9.0"
|
||||
resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.9.0.tgz#5805f403c82b48b4e136d8a97f1832aad7852bd5"
|
||||
selenium-standalone@^6.11.0:
|
||||
version "6.11.0"
|
||||
resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.11.0.tgz#eb21ff65f3b2ece75061b35d4f9e7572ac0280c2"
|
||||
dependencies:
|
||||
async "^2.1.4"
|
||||
commander "^2.9.0"
|
||||
|
||||
Reference in New Issue
Block a user