mirror of
https://github.com/scratchfoundation/scratch-www.git
synced 2024-11-22 23:27:54 -05:00
Make Selenium Helper file more resemble GUI and Update Tests to use it
This commit is contained in:
parent
3ef4278385
commit
f678c97727
9 changed files with 186 additions and 159 deletions
|
@ -1,121 +1,127 @@
|
||||||
const webdriver = require('selenium-webdriver');
|
const webdriver = require('selenium-webdriver');
|
||||||
|
const bindAll = require('lodash.bindall');
|
||||||
|
|
||||||
const headless = process.env.SMOKE_HEADLESS || false;
|
const headless = process.env.SMOKE_HEADLESS || false;
|
||||||
const remote = process.env.SMOKE_REMOTE || false;
|
const remote = process.env.SMOKE_REMOTE || false;
|
||||||
const {SAUCE_USERNAME, SAUCE_ACCESS_KEY} = process.env;
|
const {SAUCE_USERNAME, SAUCE_ACCESS_KEY} = process.env;
|
||||||
|
|
||||||
const getDriver = function () {
|
|
||||||
const chromeCapabilities = webdriver.Capabilities.chrome();
|
|
||||||
let args = [];
|
|
||||||
if (headless) {
|
|
||||||
args.push('--headless');
|
|
||||||
args.push('window-size=1024,1680');
|
|
||||||
args.push('--no-sandbox');
|
|
||||||
}
|
|
||||||
chromeCapabilities.set('chromeOptions', {args});
|
|
||||||
const newDriver = new webdriver.Builder()
|
|
||||||
.forBrowser('chrome')
|
|
||||||
.withCapabilities(chromeCapabilities)
|
|
||||||
.build();
|
|
||||||
return newDriver;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSauceDriver = function (username, accessKey, configs) {
|
|
||||||
let driver = new webdriver.Builder()
|
|
||||||
.withCapabilities({
|
|
||||||
browserName: configs.browserName,
|
|
||||||
platform: configs.platform,
|
|
||||||
version: configs.version,
|
|
||||||
username: username,
|
|
||||||
accessKey: accessKey,
|
|
||||||
name: 'smoke test scratch-www'
|
|
||||||
})
|
|
||||||
.usingServer(`http://${username}:${accessKey
|
|
||||||
}@ondemand.saucelabs.com:80/wd/hub`)
|
|
||||||
.build();
|
|
||||||
return driver;
|
|
||||||
};
|
|
||||||
// Driver configs can be generated with the Sauce Platform Configurator
|
|
||||||
// https://wiki.saucelabs.com/display/DOCS/Platform+Configurator
|
|
||||||
const driverConfig = {
|
|
||||||
browserName: 'chrome',
|
|
||||||
platform: 'macOS 10.13',
|
|
||||||
version: '67.0'
|
|
||||||
};
|
|
||||||
let driver;
|
|
||||||
if (remote === 'true'){
|
|
||||||
driver = getSauceDriver(SAUCE_USERNAME, SAUCE_ACCESS_KEY, driverConfig);
|
|
||||||
} else {
|
|
||||||
driver = getDriver();
|
|
||||||
}
|
|
||||||
|
|
||||||
const {By, until} = webdriver;
|
const {By, until} = webdriver;
|
||||||
|
|
||||||
const findByXpath = (xpath) => {
|
class SeleniumHelper {
|
||||||
return driver.wait(until.elementLocated(By.xpath(xpath), 5 * 1000));
|
constructor () {
|
||||||
};
|
bindAll(this, [
|
||||||
|
'getDriver',
|
||||||
|
'getSauceDriver',
|
||||||
|
'buildDriver',
|
||||||
|
'clickXpath',
|
||||||
|
'findByXpath',
|
||||||
|
'clickText',
|
||||||
|
'findText',
|
||||||
|
'clickButton',
|
||||||
|
'findByCss',
|
||||||
|
'clickCss',
|
||||||
|
'getLogs'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
buildDriver (name) {
|
||||||
|
if (remote === 'true'){
|
||||||
|
this.driver = this.getSauceDriver(SAUCE_USERNAME, SAUCE_ACCESS_KEY, name);
|
||||||
|
} else {
|
||||||
|
this.driver = this.getDriver();
|
||||||
|
}
|
||||||
|
return this.driver;
|
||||||
|
}
|
||||||
|
|
||||||
const clickXpath = (xpath) => {
|
getDriver () {
|
||||||
return findByXpath(xpath).then(el => el.click());
|
const chromeCapabilities = webdriver.Capabilities.chrome();
|
||||||
};
|
let args = [];
|
||||||
|
if (headless) {
|
||||||
|
args.push('--headless');
|
||||||
|
args.push('window-size=1024,1680');
|
||||||
|
args.push('--no-sandbox');
|
||||||
|
}
|
||||||
|
chromeCapabilities.set('chromeOptions', {args});
|
||||||
|
let driver = new webdriver.Builder()
|
||||||
|
.forBrowser('chrome')
|
||||||
|
.withCapabilities(chromeCapabilities)
|
||||||
|
.build();
|
||||||
|
return driver;
|
||||||
|
}
|
||||||
|
|
||||||
const clickText = (text) => {
|
getSauceDriver (username, accessKey, name) {
|
||||||
return clickXpath(`//*[contains(text(), '${text}')]`);
|
// Driver configs can be generated with the Sauce Platform Configurator
|
||||||
};
|
// https://wiki.saucelabs.com/display/DOCS/Platform+Configurator
|
||||||
|
let driverConfig = {
|
||||||
|
browserName: 'chrome',
|
||||||
|
platform: 'macOS 10.13',
|
||||||
|
version: '67.0'
|
||||||
|
};
|
||||||
|
var driver = new webdriver.Builder()
|
||||||
|
.withCapabilities({
|
||||||
|
browserName: driverConfig.browserName,
|
||||||
|
platform: driverConfig.platform,
|
||||||
|
version: driverConfig.version,
|
||||||
|
username: username,
|
||||||
|
accessKey: accessKey,
|
||||||
|
name: name
|
||||||
|
})
|
||||||
|
.usingServer(`http://${username}:${accessKey
|
||||||
|
}@ondemand.saucelabs.com:80/wd/hub`)
|
||||||
|
.build();
|
||||||
|
return driver;
|
||||||
|
}
|
||||||
|
|
||||||
const findText = (text) => {
|
findByXpath (xpath) {
|
||||||
return driver.wait(until.elementLocated(By.xpath(`//*[contains(text(), '${text}')]`), 5 * 1000));
|
return this.driver.wait(until.elementLocated(By.xpath(xpath), 5 * 1000));
|
||||||
};
|
}
|
||||||
|
|
||||||
const clickButton = (text) => {
|
clickXpath (xpath) {
|
||||||
return clickXpath(`//button[contains(text(), '${text}')]`);
|
return this.findByXpath(xpath).then(el => el.click());
|
||||||
};
|
}
|
||||||
|
|
||||||
const findByCss = (css) => {
|
clickText (text) {
|
||||||
return driver.wait(until.elementLocated(By.css(css), 1000 * 5));
|
return this.clickXpath(`//*[contains(text(), '${text}')]`);
|
||||||
};
|
}
|
||||||
|
|
||||||
const clickCss = (css) => {
|
findText (text) {
|
||||||
return findByCss(css).then(el => el.click());
|
return this.driver.wait(until.elementLocated(By.xpath(`//*[contains(text(), '${text}')]`), 5 * 1000));
|
||||||
};
|
}
|
||||||
|
|
||||||
const getLogs = (whitelist) => {
|
clickButton (text) {
|
||||||
return driver.manage()
|
return this.clickXpath(`//button[contains(text(), '${text}')]`);
|
||||||
.logs()
|
}
|
||||||
.get('browser')
|
|
||||||
.then((entries) => {
|
findByCss (css) {
|
||||||
return entries.filter((entry) => {
|
return this.driver.wait(until.elementLocated(By.css(css), 1000 * 5));
|
||||||
const message = entry.message;
|
}
|
||||||
for (let i = 0; i < whitelist.length; i++) {
|
|
||||||
if (message.indexOf(whitelist[i]) !== -1) {
|
clickCss (css) {
|
||||||
// eslint-disable-next-line no-console
|
return this.findByCss(css).then(el => el.click());
|
||||||
// console.warn('Ignoring whitelisted error: ' + whitelist[i]);
|
}
|
||||||
return false;
|
|
||||||
} else if (entry.level !== 'SEVERE') {
|
getLogs (whitelist) {
|
||||||
// eslint-disable-next-line no-console
|
return this.driver.manage()
|
||||||
// console.warn('Ignoring non-SEVERE entry: ' + message);
|
.logs()
|
||||||
return false;
|
.get('browser')
|
||||||
|
.then((entries) => {
|
||||||
|
return entries.filter((entry) => {
|
||||||
|
const message = entry.message;
|
||||||
|
for (let i = 0; i < whitelist.length; i++) {
|
||||||
|
if (message.indexOf(whitelist[i]) !== -1) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
// console.warn('Ignoring whitelisted error: ' + whitelist[i]);
|
||||||
|
return false;
|
||||||
|
} else if (entry.level !== 'SEVERE') {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
// console.warn('Ignoring non-SEVERE entry: ' + message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
});
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
}
|
||||||
webdriver,
|
|
||||||
By,
|
module.exports = SeleniumHelper;
|
||||||
until,
|
|
||||||
driver,
|
|
||||||
clickXpath,
|
|
||||||
findByXpath,
|
|
||||||
clickText,
|
|
||||||
findText,
|
|
||||||
clickButton,
|
|
||||||
findByCss,
|
|
||||||
clickCss,
|
|
||||||
getLogs,
|
|
||||||
getDriver,
|
|
||||||
getSauceDriver
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,17 +1,22 @@
|
||||||
const {
|
const SeleniumHelper = require('../selenium-helpers.js');
|
||||||
driver,
|
const helper = new SeleniumHelper();
|
||||||
findByCss,
|
|
||||||
clickCss,
|
|
||||||
until
|
|
||||||
} = require('../selenium-helpers.js');
|
|
||||||
|
|
||||||
var username = process.env.SMOKE_USERNAME;
|
|
||||||
var password = process.env.SMOKE_PASSWORD;
|
|
||||||
|
|
||||||
|
|
||||||
var tap = require('tap');
|
var tap = require('tap');
|
||||||
const test = tap.test;
|
const test = tap.test;
|
||||||
|
|
||||||
|
const webdriver = require('selenium-webdriver');
|
||||||
|
const driver = helper.buildDriver('www-smoke test-login-failures');
|
||||||
|
|
||||||
|
const {
|
||||||
|
findByCss,
|
||||||
|
clickCss
|
||||||
|
} = helper;
|
||||||
|
|
||||||
|
var until = webdriver.until;
|
||||||
|
|
||||||
|
var username = process.env.SMOKE_USERNAME;
|
||||||
|
var password = process.env.SMOKE_PASSWORD;
|
||||||
|
|
||||||
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
|
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
|
||||||
var url = rootUrl + '/users/' + username;
|
var url = rootUrl + '/users/' + username;
|
||||||
|
|
||||||
|
|
|
@ -5,21 +5,24 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const SeleniumHelper = require('../selenium-helpers.js');
|
||||||
|
const helper = new SeleniumHelper();
|
||||||
|
|
||||||
|
var tap = require('tap');
|
||||||
|
const test = tap.test;
|
||||||
|
|
||||||
|
const driver = helper.buildDriver('www-smoke test-my-stuff');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
clickText,
|
clickText,
|
||||||
findByXpath,
|
findByXpath,
|
||||||
clickXpath,
|
clickXpath,
|
||||||
clickButton,
|
clickButton
|
||||||
driver
|
} = helper;
|
||||||
} = require('../selenium-helpers.js');
|
|
||||||
|
|
||||||
var username = process.env.SMOKE_USERNAME;
|
var username = process.env.SMOKE_USERNAME;
|
||||||
var password = process.env.SMOKE_PASSWORD;
|
var password = process.env.SMOKE_PASSWORD;
|
||||||
|
|
||||||
|
|
||||||
var tap = require('tap');
|
|
||||||
const test = tap.test;
|
|
||||||
|
|
||||||
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
|
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
|
||||||
var url = rootUrl + '/users/' + username;
|
var url = rootUrl + '/users/' + username;
|
||||||
|
|
||||||
|
|
|
@ -4,12 +4,13 @@
|
||||||
* Test cases: https://github.com/LLK/scratch-www/wiki/Most-Important-Workflows
|
* Test cases: https://github.com/LLK/scratch-www/wiki/Most-Important-Workflows
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const SeleniumHelper = require('../selenium-helpers.js');
|
||||||
|
const helper = new SeleniumHelper();
|
||||||
|
|
||||||
const tap = require('tap');
|
const tap = require('tap');
|
||||||
|
|
||||||
const {
|
const webdriver = require('selenium-webdriver');
|
||||||
driver,
|
const driver = helper.buildDriver('www-smoke test_footer_links');
|
||||||
webdriver
|
|
||||||
} = require('../selenium-helpers.js');
|
|
||||||
|
|
||||||
|
|
||||||
const rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
|
const rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
|
||||||
|
|
|
@ -4,14 +4,14 @@
|
||||||
* Test cases: https://github.com/LLK/scratch-www/wiki/Most-Important-Workflows
|
* Test cases: https://github.com/LLK/scratch-www/wiki/Most-Important-Workflows
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require('chromedriver');
|
const SeleniumHelper = require('../selenium-helpers.js');
|
||||||
|
const helper = new SeleniumHelper();
|
||||||
|
|
||||||
const {
|
|
||||||
driver,
|
|
||||||
webdriver
|
|
||||||
} = require('../selenium-helpers.js');
|
|
||||||
var tap = require('tap');
|
var tap = require('tap');
|
||||||
|
|
||||||
|
const webdriver = require('selenium-webdriver');
|
||||||
|
const driver = helper.buildDriver('www-smoke test_navbar_links');
|
||||||
|
|
||||||
// Set test url through environment variable
|
// Set test url through environment variable
|
||||||
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
|
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
|
||||||
|
|
||||||
|
|
|
@ -5,13 +5,14 @@
|
||||||
* Test cases: https://github.com/LLK/scratch-www/wiki/Most-Important-Workflows
|
* Test cases: https://github.com/LLK/scratch-www/wiki/Most-Important-Workflows
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require('chromedriver');
|
const SeleniumHelper = require('../selenium-helpers.js');
|
||||||
var tap = require('tap');
|
const helper = new SeleniumHelper();
|
||||||
var seleniumWebdriver = require('selenium-webdriver');
|
|
||||||
|
var tap = require('tap');
|
||||||
|
|
||||||
|
const webdriver = require('selenium-webdriver');
|
||||||
|
const driver = helper.buildDriver('www-smoke test_project_rows');
|
||||||
|
|
||||||
const {
|
|
||||||
driver
|
|
||||||
} = require('../selenium-helpers.js');
|
|
||||||
|
|
||||||
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
|
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
|
||||||
|
|
||||||
|
@ -31,7 +32,7 @@ tap.beforeEach(function () {
|
||||||
// checks that the title of the first row is Featured Projects
|
// checks that the title of the first row is Featured Projects
|
||||||
tap.test('checkFeaturedProjectsRowTitleWhenSignedOut', function (t) {
|
tap.test('checkFeaturedProjectsRowTitleWhenSignedOut', function (t) {
|
||||||
var xPathLink = '//div[@class="box"]/div[@class="box-header"]/h4';
|
var xPathLink = '//div[@class="box"]/div[@class="box-header"]/h4';
|
||||||
driver.findElement(seleniumWebdriver.By.xpath(xPathLink))
|
driver.findElement(webdriver.By.xpath(xPathLink))
|
||||||
.then(function (element) {
|
.then(function (element) {
|
||||||
element.getText('h4')
|
element.getText('h4')
|
||||||
.then(function (text) {
|
.then(function (text) {
|
||||||
|
@ -48,8 +49,8 @@ tap.test('checkFeaturedProjectsRowLinkWhenSignedOut', function (t) {
|
||||||
var xPathLink = '//div[contains(@class, "thumbnail") ' +
|
var xPathLink = '//div[contains(@class, "thumbnail") ' +
|
||||||
'and contains(@class, "project") and contains(@class, "slick-slide") ' +
|
'and contains(@class, "project") and contains(@class, "slick-slide") ' +
|
||||||
'and contains(@class, "slick-active")]/a[@class="thumbnail-image"]';
|
'and contains(@class, "slick-active")]/a[@class="thumbnail-image"]';
|
||||||
driver.wait(seleniumWebdriver.until
|
driver.wait(webdriver.until
|
||||||
.elementLocated(seleniumWebdriver.By.xpath(xPathLink)))
|
.elementLocated(webdriver.By.xpath(xPathLink)))
|
||||||
.then(function (element) {
|
.then(function (element) {
|
||||||
element.getAttribute('href')
|
element.getAttribute('href')
|
||||||
.then(function (url) {
|
.then(function (url) {
|
||||||
|
@ -65,7 +66,7 @@ tap.test('checkFeaturedProjectsRowLinkWhenSignedOut', function (t) {
|
||||||
// checks that the title of the 2nd row is Featured Studios
|
// checks that the title of the 2nd row is Featured Studios
|
||||||
tap.test('checkFeaturedStudiosRowWhenSignedOut', function (t) {
|
tap.test('checkFeaturedStudiosRowWhenSignedOut', function (t) {
|
||||||
var xPathLink = '//div[@class="box"][2]/div[@class="box-header"]/h4';
|
var xPathLink = '//div[@class="box"][2]/div[@class="box-header"]/h4';
|
||||||
driver.findElement(seleniumWebdriver.By.xpath(xPathLink))
|
driver.findElement(webdriver.By.xpath(xPathLink))
|
||||||
.then(function (element) {
|
.then(function (element) {
|
||||||
element.getText('h4')
|
element.getText('h4')
|
||||||
.then(function (text) {
|
.then(function (text) {
|
||||||
|
@ -80,7 +81,7 @@ tap.test('checkFeaturedStudiosRowWhenSignedOut', function (t) {
|
||||||
tap.test('checkFeaturedStudiosRowLinkWhenSignedOut', function (t) {
|
tap.test('checkFeaturedStudiosRowLinkWhenSignedOut', function (t) {
|
||||||
var xPathLink = '//div[contains(@class, "thumbnail") and contains(@class, "gallery") ' +
|
var xPathLink = '//div[contains(@class, "thumbnail") and contains(@class, "gallery") ' +
|
||||||
'and contains(@class, "slick-slide") and contains(@class, "slick-active")]/a[@class="thumbnail-image"]';
|
'and contains(@class, "slick-slide") and contains(@class, "slick-active")]/a[@class="thumbnail-image"]';
|
||||||
driver.findElement(seleniumWebdriver.By.xpath(xPathLink))
|
driver.findElement(webdriver.By.xpath(xPathLink))
|
||||||
.then(function (element) {
|
.then(function (element) {
|
||||||
element.getAttribute('href')
|
element.getAttribute('href')
|
||||||
.then(function (url) {
|
.then(function (url) {
|
||||||
|
|
|
@ -5,22 +5,25 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const SeleniumHelper = require('../selenium-helpers.js');
|
||||||
|
const helper = new SeleniumHelper();
|
||||||
|
|
||||||
|
var tap = require('tap');
|
||||||
|
const test = tap.test;
|
||||||
|
|
||||||
|
const driver = helper.buildDriver('www-smoke test_sign_in_out_discuss');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
clickText,
|
clickText,
|
||||||
findByXpath,
|
findByXpath,
|
||||||
findText,
|
findText,
|
||||||
clickXpath,
|
clickXpath,
|
||||||
clickButton,
|
clickButton
|
||||||
driver
|
} = helper;
|
||||||
} = require('../selenium-helpers.js');
|
|
||||||
|
|
||||||
var username = process.env.SMOKE_USERNAME;
|
var username = process.env.SMOKE_USERNAME;
|
||||||
var password = process.env.SMOKE_PASSWORD;
|
var password = process.env.SMOKE_PASSWORD;
|
||||||
|
|
||||||
|
|
||||||
var tap = require('tap');
|
|
||||||
const test = tap.test;
|
|
||||||
|
|
||||||
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
|
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
|
||||||
var url = rootUrl + '/discuss';
|
var url = rootUrl + '/discuss';
|
||||||
|
|
||||||
|
|
|
@ -5,20 +5,24 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const SeleniumHelper = require('../selenium-helpers.js');
|
||||||
|
const helper = new SeleniumHelper();
|
||||||
|
|
||||||
|
var tap = require('tap');
|
||||||
|
const test = tap.test;
|
||||||
|
|
||||||
|
const driver = helper.buildDriver('www-smoke test_sign_in_out_homepage');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
clickText,
|
clickText,
|
||||||
findText,
|
findText,
|
||||||
findByXpath,
|
findByXpath,
|
||||||
clickXpath,
|
clickXpath
|
||||||
driver
|
} = helper;
|
||||||
} = require('../selenium-helpers.js');
|
|
||||||
|
|
||||||
var username = process.env.SMOKE_USERNAME;
|
var username = process.env.SMOKE_USERNAME;
|
||||||
var password = process.env.SMOKE_PASSWORD;
|
var password = process.env.SMOKE_PASSWORD;
|
||||||
|
|
||||||
var tap = require('tap');
|
|
||||||
const test = tap.test;
|
|
||||||
|
|
||||||
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
|
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
|
||||||
|
|
||||||
tap.plan(2);
|
tap.plan(2);
|
||||||
|
|
|
@ -5,16 +5,20 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const {
|
const SeleniumHelper = require('../selenium-helpers.js');
|
||||||
clickText,
|
const helper = new SeleniumHelper();
|
||||||
findByXpath,
|
|
||||||
findByCss,
|
|
||||||
driver
|
|
||||||
} = require('../selenium-helpers.js');
|
|
||||||
|
|
||||||
var tap = require('tap');
|
var tap = require('tap');
|
||||||
const test = tap.test;
|
const test = tap.test;
|
||||||
|
|
||||||
|
const driver = helper.buildDriver('www-smoke test_statistics_page');
|
||||||
|
|
||||||
|
const {
|
||||||
|
clickText,
|
||||||
|
findByXpath,
|
||||||
|
findByCss
|
||||||
|
} = helper;
|
||||||
|
|
||||||
tap.plan(2);
|
tap.plan(2);
|
||||||
|
|
||||||
tap.tearDown(function () {
|
tap.tearDown(function () {
|
||||||
|
|
Loading…
Reference in a new issue