mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-10 23:36:14 -05:00
Added CasperJS Functional Test to Build
Fixes #363 - Added new grunt task to run casperjs tests. - Added prerequisites (sass/bourbon/casperjs) to travis config. - Updated failing functional tests to use more robust `waitFor` statements. - Updated capserjs `base.js` file to use a password which conforms to our 8 character minimum. - Added necessary logout to first test and also registration step to ensure a user is present in the system.
This commit is contained in:
parent
1efd16dbcc
commit
badd6d10d4
8 changed files with 210 additions and 66 deletions
13
.travis.yml
13
.travis.yml
|
@ -4,5 +4,16 @@ node_js:
|
||||||
- "0.10"
|
- "0.10"
|
||||||
git:
|
git:
|
||||||
submodules: false
|
submodules: false
|
||||||
before_script:
|
before_install:
|
||||||
|
- gem update --system
|
||||||
|
- gem install sass bourbon
|
||||||
- npm install -g grunt-cli
|
- npm install -g grunt-cli
|
||||||
|
- git clone git://github.com/n1k0/casperjs.git ~/casperjs
|
||||||
|
- cd ~/casperjs
|
||||||
|
- git checkout tags/1.1-beta1
|
||||||
|
- export PATH=$PATH:`pwd`/bin
|
||||||
|
- cd -
|
||||||
|
before_script:
|
||||||
|
- phantomjs --version
|
||||||
|
- casperjs --version
|
||||||
|
- grunt init
|
35
Gruntfile.js
35
Gruntfile.js
|
@ -6,6 +6,8 @@ var path = require('path'),
|
||||||
spawn = require("child_process").spawn,
|
spawn = require("child_process").spawn,
|
||||||
buildDirectory = path.resolve(process.cwd(), '.build'),
|
buildDirectory = path.resolve(process.cwd(), '.build'),
|
||||||
distDirectory = path.resolve(process.cwd(), '.dist'),
|
distDirectory = path.resolve(process.cwd(), '.dist'),
|
||||||
|
config = require('./config'),
|
||||||
|
_ = require('underscore'),
|
||||||
configureGrunt = function (grunt) {
|
configureGrunt = function (grunt) {
|
||||||
|
|
||||||
// load all grunt tasks
|
// load all grunt tasks
|
||||||
|
@ -343,6 +345,34 @@ var path = require('path'),
|
||||||
cfg.buildType = type;
|
cfg.buildType = type;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
grunt.registerTask('spawn-casperjs', function () {
|
||||||
|
var done = this.async(),
|
||||||
|
options = ['host', 'noPort', 'port', 'email', 'password'],
|
||||||
|
args = ['test', 'admin/', '--includes=base.js', '--direct', '--log-level=debug'];
|
||||||
|
|
||||||
|
// Forward parameters from grunt to casperjs
|
||||||
|
_.each(options, function processOption(option) {
|
||||||
|
if (grunt.option(option)) {
|
||||||
|
args.push('--' + option + '=' + grunt.option(option));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
grunt.util.spawn({
|
||||||
|
cmd: 'casperjs',
|
||||||
|
args: args,
|
||||||
|
opts: {
|
||||||
|
cwd: path.resolve('core/test/functional'),
|
||||||
|
stdio: 'inherit'
|
||||||
|
}
|
||||||
|
}, function (error, result, code) {
|
||||||
|
if (error) {
|
||||||
|
grunt.fail.fatal(result.stdout);
|
||||||
|
}
|
||||||
|
grunt.log.writeln(result.stdout);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Prepare the project for development
|
// Prepare the project for development
|
||||||
// TODO: Git submodule init/update (https://github.com/jaubourg/grunt-update-submodules)?
|
// TODO: Git submodule init/update (https://github.com/jaubourg/grunt-update-submodules)?
|
||||||
grunt.registerTask("init", ["shell:bourbon", "sass:admin", 'handlebars']);
|
grunt.registerTask("init", ["shell:bourbon", "sass:admin", 'handlebars']);
|
||||||
|
@ -356,8 +386,11 @@ var path = require('path'),
|
||||||
// Run migrations tests only
|
// Run migrations tests only
|
||||||
grunt.registerTask("test-m", ["mochacli:migrate"]);
|
grunt.registerTask("test-m", ["mochacli:migrate"]);
|
||||||
|
|
||||||
|
// Run casperjs tests only
|
||||||
|
grunt.registerTask('test-functional', ['express', 'spawn-casperjs']);
|
||||||
|
|
||||||
// Run tests and lint code
|
// Run tests and lint code
|
||||||
grunt.registerTask("validate", ["jslint", "mochacli:all"]);
|
grunt.registerTask("validate", ["jslint", "mochacli:all", "test-functional"]);
|
||||||
|
|
||||||
// Generate Docs
|
// Generate Docs
|
||||||
grunt.registerTask("docs", ["groc"]);
|
grunt.registerTask("docs", ["groc"]);
|
||||||
|
|
|
@ -1,4 +1,59 @@
|
||||||
/*globals casper, __utils__, url, user, falseUser */
|
/*globals casper, __utils__, url, user, falseUser */
|
||||||
|
|
||||||
|
casper.test.begin('Ensure Session is Killed', 1, function suite(test) {
|
||||||
|
casper.test.filename = 'login_logout_test.png';
|
||||||
|
|
||||||
|
casper.start(url + 'logout/', function (response) {
|
||||||
|
test.assert(/\/ghost\//.test(response.url), response.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
casper.run(function () {
|
||||||
|
test.done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
casper.test.begin('Ensure a User is Registered', 2, function suite(test) {
|
||||||
|
casper.test.filename = 'login_user_registered_test.png';
|
||||||
|
|
||||||
|
casper.start(url + 'ghost/signup/');
|
||||||
|
|
||||||
|
casper.waitFor(function checkOpaque() {
|
||||||
|
return this.evaluate(function () {
|
||||||
|
var loginBox = document.querySelector('.login-box');
|
||||||
|
return window.getComputedStyle(loginBox).getPropertyValue('display') === "block"
|
||||||
|
&& window.getComputedStyle(loginBox).getPropertyValue('opacity') === "1";
|
||||||
|
});
|
||||||
|
}, function then() {
|
||||||
|
checkUrl = true;
|
||||||
|
this.fill("#register", user, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
casper.waitForSelectorTextChange('.notification-error', function (text) {
|
||||||
|
test.assertSelectorHasText('.notification-error', 'already registered');
|
||||||
|
// If the previous assert succeeds, then we should skip the next check and just pass.
|
||||||
|
test.pass('Already registered!');
|
||||||
|
}, function () {
|
||||||
|
test.assertUrlMatch(/\/ghost\/$/, 'If we\'re not already registered, we should be logged in.');
|
||||||
|
test.pass('Successfully registered.')
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
casper.run(function () {
|
||||||
|
test.done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
casper.test.begin('Ensure Session is Killed after Registration', 1, function suite(test) {
|
||||||
|
casper.test.filename = 'login_logout2_test.png';
|
||||||
|
|
||||||
|
casper.start(url + 'logout/', function (response) {
|
||||||
|
test.assert(/\/ghost\//.test(response.url), response.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
casper.run(function () {
|
||||||
|
test.done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
casper.test.begin("Ghost admin will load login page", 2, function suite(test) {
|
casper.test.begin("Ghost admin will load login page", 2, function suite(test) {
|
||||||
|
|
||||||
casper.test.filename = "admin_test.png";
|
casper.test.filename = "admin_test.png";
|
||||||
|
@ -26,9 +81,9 @@ casper.test.begin('Redirects to signin', 2, function suite(test) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.test.begin("Can login to Ghost", 3, function suite(test) {
|
casper.test.begin("Can't spam it", 2, function suite(test) {
|
||||||
|
|
||||||
casper.test.filename = "login_test.png";
|
casper.test.filename = "login_spam_test.png";
|
||||||
|
|
||||||
casper.start(url + "ghost/signin/", function testTitle() {
|
casper.start(url + "ghost/signin/", function testTitle() {
|
||||||
test.assertTitle("", "Ghost admin has no title");
|
test.assertTitle("", "Ghost admin has no title");
|
||||||
|
@ -42,6 +97,39 @@ casper.test.begin("Can login to Ghost", 3, function suite(test) {
|
||||||
&& window.getComputedStyle(loginBox).getPropertyValue('opacity') === "1";
|
&& window.getComputedStyle(loginBox).getPropertyValue('opacity') === "1";
|
||||||
});
|
});
|
||||||
}, function then() {
|
}, function then() {
|
||||||
|
this.fill("#login", falseUser, true);
|
||||||
|
casper.wait(200, function doneWait() {
|
||||||
|
this.fill("#login", falseUser, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
casper.wait(1000, function doneWait() {
|
||||||
|
this.echo("I've waited for 1 seconds.");
|
||||||
|
});
|
||||||
|
|
||||||
|
casper.then(function testForErrorMessage() {
|
||||||
|
test.assertSelectorHasText('.notification-error', 'Slow down, there are way too many login attempts!');
|
||||||
|
});
|
||||||
|
|
||||||
|
casper.run(function () {
|
||||||
|
test.done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
casper.test.begin("Can login to Ghost", 3, function suite(test) {
|
||||||
|
|
||||||
|
casper.test.filename = "login_test.png";
|
||||||
|
|
||||||
|
casper.start(url + "ghost/login/", function testTitle() {
|
||||||
|
test.assertTitle("", "Ghost admin has no title");
|
||||||
|
}).viewport(1280, 1024);
|
||||||
|
|
||||||
|
casper.waitFor(function checkOpaque() {
|
||||||
|
return this.evaluate(function () {
|
||||||
|
var loginBox = document.querySelector('.login-box');
|
||||||
|
return window.getComputedStyle(loginBox).getPropertyValue('display') === "block"
|
||||||
|
&& window.getComputedStyle(loginBox).getPropertyValue('opacity') === "1";
|
||||||
|
});
|
||||||
|
}, function then() {
|
||||||
this.fill("#login", user, true);
|
this.fill("#login", user, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -58,38 +146,3 @@ casper.test.begin("Can login to Ghost", 3, function suite(test) {
|
||||||
test.done();
|
test.done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.test.begin("Can't spam it", 2, function suite(test) {
|
|
||||||
|
|
||||||
casper.test.filename = "login_test.png";
|
|
||||||
|
|
||||||
casper.start(url + "ghost/login/", function testTitle() {
|
|
||||||
test.assertTitle("", "Ghost admin has no title");
|
|
||||||
}).viewport(1280, 1024);
|
|
||||||
|
|
||||||
casper.waitFor(function checkOpaque() {
|
|
||||||
return this.evaluate(function () {
|
|
||||||
var loginBox = document.querySelector('.login-box');
|
|
||||||
return window.getComputedStyle(loginBox).getPropertyValue('display') === "block"
|
|
||||||
&& window.getComputedStyle(loginBox).getPropertyValue('opacity') === "1";
|
|
||||||
});
|
|
||||||
}, function then() {
|
|
||||||
this.fill("#login", falseUser, true);
|
|
||||||
casper.wait(200, function doneWait() {
|
|
||||||
this.fill("#login", falseUser, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
casper.wait(200, function doneWait() {
|
|
||||||
this.echo("I've waited for 1 seconds.");
|
|
||||||
});
|
|
||||||
|
|
||||||
casper.then(function testForErrorMessage() {
|
|
||||||
test.assertSelectorHasText('.notification-error', 'Slow down, there are way too many login attempts!');
|
|
||||||
});
|
|
||||||
|
|
||||||
casper.run(function () {
|
|
||||||
test.done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
|
@ -64,7 +64,7 @@ casper.test.begin("Haunted markdown in editor works", 3, function suite(test) {
|
||||||
}).viewport(1280, 1024);
|
}).viewport(1280, 1024);
|
||||||
|
|
||||||
casper.then(function testImage() {
|
casper.then(function testImage() {
|
||||||
casper.writeContentToCodeMirror("![some text]()");
|
casper.writeContentToCodeMirror("![sometext]()");
|
||||||
});
|
});
|
||||||
|
|
||||||
// We must wait after sending keys to CodeMirror
|
// We must wait after sending keys to CodeMirror
|
||||||
|
@ -77,9 +77,9 @@ casper.test.begin("Haunted markdown in editor works", 3, function suite(test) {
|
||||||
|
|
||||||
test.assertEvalEquals(function () {
|
test.assertEvalEquals(function () {
|
||||||
return document.querySelector('.CodeMirror-wrap textarea').value;
|
return document.querySelector('.CodeMirror-wrap textarea').value;
|
||||||
}, "![some text]()", 'Editor value is correct');
|
}, "![sometext]()", 'Editor value is correct');
|
||||||
|
|
||||||
test.assertSelectorHasText('.entry-preview .rendered-markdown', 'Add image of some text', 'Editor value is correct');
|
test.assertSelectorHasText('.entry-preview .rendered-markdown', 'Add image of sometext', 'Editor value is correct');
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.run(function () {
|
casper.run(function () {
|
||||||
|
|
|
@ -82,7 +82,12 @@ casper.test.begin("Settings screen is correct", 19, function suite(test) {
|
||||||
|
|
||||||
casper.then(function checkUserWasSaved() {
|
casper.then(function checkUserWasSaved() {
|
||||||
casper.removeListener('resource.requested', handleUserRequest);
|
casper.removeListener('resource.requested', handleUserRequest);
|
||||||
test.assertExists('.notification-success', 'got success notification');
|
});
|
||||||
|
|
||||||
|
casper.waitForSelector('.notification-success', function onSuccess() {
|
||||||
|
test.assert(true, 'Got success notification');
|
||||||
|
}, function onTimeout() {
|
||||||
|
test.assert(false, 'No success notification :(');
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.thenClick('#main-menu .settings a').then(function testOpeningSettingsTwice() {
|
casper.thenClick('#main-menu .settings a').then(function testOpeningSettingsTwice() {
|
||||||
|
@ -105,7 +110,12 @@ casper.test.begin("Settings screen is correct", 19, function suite(test) {
|
||||||
|
|
||||||
casper.then(function checkSettingsWereSaved() {
|
casper.then(function checkSettingsWereSaved() {
|
||||||
casper.removeListener('resource.requested', handleSettingsRequest);
|
casper.removeListener('resource.requested', handleSettingsRequest);
|
||||||
test.assertExists('.notification-success', 'got success notification');
|
});
|
||||||
|
|
||||||
|
casper.waitForSelector('.notification-success', function onSuccess() {
|
||||||
|
test.assert(true, 'Got success notification');
|
||||||
|
}, function onTimeout() {
|
||||||
|
test.assert(false, 'No success notification :(');
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.run(function () {
|
casper.run(function () {
|
||||||
|
@ -134,9 +144,15 @@ casper.test.begin("User settings screen validates email", 6, function suite(test
|
||||||
}, false);
|
}, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.thenClick('#user .button-save').waitForResource('/users/', function () {
|
casper.thenClick('#user .button-save');
|
||||||
test.assertExists('.notification-error', 'got error notification');
|
|
||||||
|
casper.waitForResource('/users/');
|
||||||
|
|
||||||
|
casper.waitForSelector('.notification-error', function onSuccess() {
|
||||||
|
test.assert(true, 'Got error notification');
|
||||||
test.assertSelectorDoesntHaveText('.notification-error', '[object Object]');
|
test.assertSelectorDoesntHaveText('.notification-error', '[object Object]');
|
||||||
|
}, function onTimeout() {
|
||||||
|
test.assert(false, 'No error notification :(');
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.then(function resetEmailToValid() {
|
casper.then(function resetEmailToValid() {
|
||||||
|
@ -145,9 +161,15 @@ casper.test.begin("User settings screen validates email", 6, function suite(test
|
||||||
}, false);
|
}, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.thenClick('#user .button-save').waitForResource('/users/', function () {
|
casper.thenClick('#user .button-save');
|
||||||
test.assertExists('.notification-success', 'got success notification');
|
|
||||||
|
casper.waitForResource('/users/');
|
||||||
|
|
||||||
|
casper.waitForSelector('.notification-success', function onSuccess() {
|
||||||
|
test.assert(true, 'Got success notification');
|
||||||
test.assertSelectorDoesntHaveText('.notification-success', '[object Object]');
|
test.assertSelectorDoesntHaveText('.notification-success', '[object Object]');
|
||||||
|
}, function onTimeout() {
|
||||||
|
test.assert(false, 'No success notification :(');
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.run(function () {
|
casper.run(function () {
|
||||||
|
|
|
@ -22,8 +22,14 @@ casper.test.begin("Ghost edit draft flow works correctly", 7, function suite(tes
|
||||||
this.echo("I've waited for 1 seconds.");
|
this.echo("I've waited for 1 seconds.");
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.thenClick('.button-save').waitForResource(/posts/, function then() {
|
casper.thenClick('.button-save');
|
||||||
test.assertExists('.notification-success', 'got success notification');
|
|
||||||
|
casper.waitForResource(/posts/);
|
||||||
|
|
||||||
|
casper.waitForSelector('.notification-success', function onSuccess() {
|
||||||
|
test.assert(true, 'Got success notification');
|
||||||
|
}, function onTimeout() {
|
||||||
|
test.assert(false, 'No success notification :(');
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.thenOpen(url + 'ghost/content/', function then() {
|
casper.thenOpen(url + 'ghost/content/', function then() {
|
||||||
|
@ -42,8 +48,14 @@ casper.test.begin("Ghost edit draft flow works correctly", 7, function suite(tes
|
||||||
test.assertUrlMatch(/editor/, "Ghost doesn't require login this time");
|
test.assertUrlMatch(/editor/, "Ghost doesn't require login this time");
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.thenClick('.button-save').waitForResource(/posts/, function then() {
|
casper.thenClick('.button-save');
|
||||||
test.assertExists('.notification-success', 'got success notification');
|
|
||||||
|
casper.waitForResource(/posts/);
|
||||||
|
|
||||||
|
casper.waitForSelector('.notification-success', function onSuccess() {
|
||||||
|
test.assert(true, 'Got success notification');
|
||||||
|
}, function onTimeout() {
|
||||||
|
test.assert(false, 'No success notification :(');
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.run(function () {
|
casper.run(function () {
|
||||||
|
|
|
@ -19,8 +19,14 @@ casper.test.begin("Ghost logout works correctly", 2, function suite(test) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.thenClick('.usermenu-signout a').waitForResource(/login/, function then() {
|
casper.thenClick('.usermenu-signout a');
|
||||||
test.assertExists('.notification-success', 'got success notification');
|
|
||||||
|
casper.waitForResource(/signin/);
|
||||||
|
|
||||||
|
casper.waitForSelector('.notification-success', function onSuccess() {
|
||||||
|
test.assert(true, 'Got success notification');
|
||||||
|
}, function onTimeout() {
|
||||||
|
test.assert(false, 'No success notification :(');
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.run(function () {
|
casper.run(function () {
|
||||||
|
@ -50,13 +56,12 @@ casper.test.begin("Can't spam signin", 3, function suite(test) {
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
casper.wait(200, function doneWait() {
|
|
||||||
this.echo("I've waited for 1 seconds.");
|
|
||||||
});
|
|
||||||
|
|
||||||
casper.then(function testForErrorMessage() {
|
casper.waitForSelector('.notification-error', function onSuccess() {
|
||||||
test.assertExists('.notification-error', 'got error notification');
|
test.assert(true, 'Got error notification');
|
||||||
test.assertSelectorDoesntHaveText('.notification-error', '[object Object]');
|
test.assertSelectorDoesntHaveText('.notification-error', '[object Object]');
|
||||||
|
}, function onTimeout() {
|
||||||
|
test.assert(false, 'No error notification :(');
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.run(function () {
|
casper.run(function () {
|
||||||
|
@ -77,9 +82,13 @@ casper.test.begin("Ghost signup fails properly", 5, function suite(test) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// should now throw a short password error
|
// should now throw a short password error
|
||||||
casper.waitForResource(/signup/, function () {
|
casper.waitForResource(/signup/);
|
||||||
test.assertExists('.notification-error', 'got error notification');
|
|
||||||
|
casper.waitForSelector('.notification-error', function onSuccess() {
|
||||||
|
test.assert(true, 'Got error notification');
|
||||||
test.assertSelectorDoesntHaveText('.notification-error', '[object Object]');
|
test.assertSelectorDoesntHaveText('.notification-error', '[object Object]');
|
||||||
|
}, function onTimeout() {
|
||||||
|
test.assert(false, 'No error notification :(');
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.then(function signupWithLongPassword() {
|
casper.then(function signupWithLongPassword() {
|
||||||
|
@ -87,9 +96,13 @@ casper.test.begin("Ghost signup fails properly", 5, function suite(test) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// should now throw a 1 user only error
|
// should now throw a 1 user only error
|
||||||
casper.waitForResource(/signup/, function () {
|
casper.waitForResource(/signup/);
|
||||||
test.assertExists('.notification-error', 'got error notification');
|
|
||||||
|
casper.waitForSelector('.notification-error', function onSuccess() {
|
||||||
|
test.assert(true, 'Got error notification');
|
||||||
test.assertSelectorDoesntHaveText('.notification-error', '[object Object]');
|
test.assertSelectorDoesntHaveText('.notification-error', '[object Object]');
|
||||||
|
}, function onTimeout() {
|
||||||
|
test.assert(false, 'No error notification :(');
|
||||||
});
|
});
|
||||||
|
|
||||||
casper.run(function () {
|
casper.run(function () {
|
||||||
|
|
|
@ -25,7 +25,7 @@ var host = casper.cli.options.host || 'localhost',
|
||||||
noPort = casper.cli.options.noPort || false,
|
noPort = casper.cli.options.noPort || false,
|
||||||
port = casper.cli.options.port || '2368',
|
port = casper.cli.options.port || '2368',
|
||||||
email = casper.cli.options.email || 'ghost@tryghost.org',
|
email = casper.cli.options.email || 'ghost@tryghost.org',
|
||||||
password = casper.cli.options.password || 'Sl1m3r',
|
password = casper.cli.options.password || 'Sl1m3rson',
|
||||||
url = "http://" + host + (noPort ? '/' : ":" + port + "/"),
|
url = "http://" + host + (noPort ? '/' : ":" + port + "/"),
|
||||||
user = {
|
user = {
|
||||||
email: email,
|
email: email,
|
||||||
|
|
Loading…
Add table
Reference in a new issue