Add browser test server
This commit is contained in:
parent
41bc8a3172
commit
9515554647
37
Gruntfile.js
37
Gruntfile.js
|
@ -16,26 +16,46 @@ module.exports = function(grunt) {
|
|||
banner: '<%= banner %>'
|
||||
},
|
||||
dist: {
|
||||
src: '<%= concat.dist.dest %>',
|
||||
dest: 'dist/<%= pkg.name %>.min.js'
|
||||
files: {
|
||||
'dist/loopback.min.js': ['dist/loopback.js']
|
||||
}
|
||||
}
|
||||
},
|
||||
jshint: {
|
||||
options: {
|
||||
curly: true,
|
||||
curly: false,
|
||||
eqeqeq: true,
|
||||
immed: true,
|
||||
latedef: true,
|
||||
latedef: false,
|
||||
newcap: true,
|
||||
noarg: true,
|
||||
sub: true,
|
||||
undef: true,
|
||||
unused: true,
|
||||
unused: false,
|
||||
boss: true,
|
||||
eqnull: true,
|
||||
browser: true,
|
||||
asi: true,
|
||||
node: true,
|
||||
laxbreak: true,
|
||||
globals: {
|
||||
jQuery: true
|
||||
require: true,
|
||||
jQuery: true,
|
||||
process: true,
|
||||
/* MOCHA */
|
||||
describe: false,
|
||||
it: false,
|
||||
before: false,
|
||||
beforeEach: false,
|
||||
after: false,
|
||||
afterEach: false,
|
||||
assert: false,
|
||||
request: false,
|
||||
app: false,
|
||||
loopback: false,
|
||||
expect: true,
|
||||
GeoPoint: true,
|
||||
assertValidDataSource: true
|
||||
}
|
||||
},
|
||||
gruntfile: {
|
||||
|
@ -61,6 +81,7 @@ module.exports = function(grunt) {
|
|||
'dist/loopback.js': ['index.js'],
|
||||
},
|
||||
options: {
|
||||
ignore: ['nodemailer', 'passport']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -96,13 +117,13 @@ module.exports = function(grunt) {
|
|||
// todo appium
|
||||
|
||||
// These plugins provide necessary tasks.
|
||||
grunt.loadNpmTasks('grunt-browserify');
|
||||
grunt.loadNpmTasks('grunt-contrib-uglify');
|
||||
grunt.loadNpmTasks('grunt-contrib-jshint');
|
||||
grunt.loadNpmTasks('grunt-contrib-watch');
|
||||
grunt.loadNpmTasks('grunt-browserify');
|
||||
grunt.loadNpmTasks('grunt-mocha-selenium');
|
||||
|
||||
// Default task.
|
||||
grunt.registerTask('default', ['jshint', 'uglify']);
|
||||
grunt.registerTask('default', ['browserify', 'mochaSelenium']);
|
||||
|
||||
};
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -18,7 +18,6 @@ var express = require('express')
|
|||
* methods to create models and data sources. The module itself is a function
|
||||
* that creates loopback `app`. For example,
|
||||
*
|
||||
*
|
||||
* ```js
|
||||
* var loopback = require('loopback');
|
||||
* var app = loopback();
|
||||
|
@ -27,6 +26,18 @@ var express = require('express')
|
|||
|
||||
var loopback = exports = module.exports = createApplication;
|
||||
|
||||
/**
|
||||
* Is this a browser environment?
|
||||
*/
|
||||
|
||||
loopback.isBrowser = typeof window !== 'undefined';
|
||||
|
||||
/**
|
||||
* Is this a server environment?
|
||||
*/
|
||||
|
||||
loopback.isServer = !loopback.isBrowser;
|
||||
|
||||
/**
|
||||
* Framework version.
|
||||
*/
|
||||
|
@ -320,11 +331,11 @@ var dataSourceTypes = {
|
|||
MAIL: 'mail'
|
||||
};
|
||||
|
||||
loopback.Email.autoAttach = dataSourceTypes.MAIL;
|
||||
loopback.User.autoAttach = dataSourceTypes.DB;
|
||||
loopback.AccessToken.autoAttach = dataSourceTypes.DB;
|
||||
loopback.Role.autoAttach = dataSourceTypes.DB;
|
||||
loopback.RoleMapping.autoAttach = dataSourceTypes.DB;
|
||||
loopback.AccessToken.autoAttach = dataSourceTypes.DB;
|
||||
if(loopback.isServer) loopback.Email.autoAttach = dataSourceTypes.MAIL;
|
||||
loopback.ACL.autoAttach = dataSourceTypes.DB;
|
||||
loopback.Scope.autoAttach = dataSourceTypes.DB;
|
||||
loopback.Application.autoAttach = dataSourceTypes.DB;
|
||||
|
|
|
@ -49,7 +49,10 @@
|
|||
"browserify": "~3.14.1",
|
||||
"grunt": "~0.4.2",
|
||||
"grunt-browserify": "~1.3.0",
|
||||
"grunt-mocha-selenium": "~0.7.0"
|
||||
"grunt-mocha-selenium": "~0.7.0",
|
||||
"grunt-contrib-uglify": "~0.3.2",
|
||||
"grunt-contrib-jshint": "~0.8.0",
|
||||
"grunt-contrib-watch": "~0.5.3"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,47 @@
|
|||
// test server
|
||||
var loopback = require('../../');
|
||||
var testServer = loopback();
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var browserify = require('browserify');
|
||||
var TEST_DIR = path.join(__dirname, '..');
|
||||
var FIXTURES_DIR = path.join(TEST_DIR, 'fixtures');
|
||||
|
||||
testServer.set('views', __dirname);
|
||||
|
||||
testServer.get('/', function(req, res) {
|
||||
res.render('test.html.ejs');
|
||||
});
|
||||
|
||||
testServer.get('/loopback.js', function(req, res) {
|
||||
res.send(
|
||||
fs.readFileSync(path.join(__dirname, '..', '..', 'dist', 'loopback.js'))
|
||||
)
|
||||
});
|
||||
|
||||
testServer.get('/tests.js', function(req, res) {
|
||||
var files = [
|
||||
path.join(TEST_DIR, 'support.js'),
|
||||
path.join(TEST_DIR, 'model.test.js'),
|
||||
path.join(TEST_DIR, 'change.test.js'),
|
||||
path.join(TEST_DIR, 'geo-point.test.js')
|
||||
];
|
||||
var b = browserify({
|
||||
entries: files,
|
||||
basedir: TEST_DIR,
|
||||
debug: true
|
||||
});
|
||||
b.ignore('nodemailer');
|
||||
b.ignore('passport');
|
||||
b.ignore('superagent');
|
||||
b.ignore('supertest');
|
||||
b.bundle({
|
||||
debug: true
|
||||
}).pipe(res);
|
||||
});
|
||||
|
||||
testServer.use(loopback.static(__dirname));
|
||||
|
||||
testServer.listen(4040, function() {
|
||||
console.log('test server listening on port', testServer.get('port'));
|
||||
});
|
|
@ -0,0 +1,270 @@
|
|||
@charset "utf-8";
|
||||
|
||||
body {
|
||||
margin:0;
|
||||
}
|
||||
|
||||
#mocha {
|
||||
font: 20px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
margin: 60px 50px;
|
||||
}
|
||||
|
||||
#mocha ul,
|
||||
#mocha li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#mocha ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#mocha h1,
|
||||
#mocha h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#mocha h1 {
|
||||
margin-top: 15px;
|
||||
font-size: 1em;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
#mocha h1 a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#mocha h1 a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#mocha .suite .suite h1 {
|
||||
margin-top: 0;
|
||||
font-size: .8em;
|
||||
}
|
||||
|
||||
#mocha .hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#mocha h2 {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#mocha .suite {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
#mocha .test {
|
||||
margin-left: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#mocha .test.pending:hover h2::after {
|
||||
content: '(pending)';
|
||||
font-family: arial, sans-serif;
|
||||
}
|
||||
|
||||
#mocha .test.pass.medium .duration {
|
||||
background: #c09853;
|
||||
}
|
||||
|
||||
#mocha .test.pass.slow .duration {
|
||||
background: #b94a48;
|
||||
}
|
||||
|
||||
#mocha .test.pass::before {
|
||||
content: '✓';
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
float: left;
|
||||
margin-right: 5px;
|
||||
color: #00d6b2;
|
||||
}
|
||||
|
||||
#mocha .test.pass .duration {
|
||||
font-size: 9px;
|
||||
margin-left: 5px;
|
||||
padding: 2px 5px;
|
||||
color: #fff;
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.2);
|
||||
-moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.2);
|
||||
box-shadow: inset 0 1px 1px rgba(0,0,0,.2);
|
||||
-webkit-border-radius: 5px;
|
||||
-moz-border-radius: 5px;
|
||||
-ms-border-radius: 5px;
|
||||
-o-border-radius: 5px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#mocha .test.pass.fast .duration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#mocha .test.pending {
|
||||
color: #0b97c4;
|
||||
}
|
||||
|
||||
#mocha .test.pending::before {
|
||||
content: '◦';
|
||||
color: #0b97c4;
|
||||
}
|
||||
|
||||
#mocha .test.fail {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
#mocha .test.fail pre {
|
||||
color: black;
|
||||
}
|
||||
|
||||
#mocha .test.fail::before {
|
||||
content: '✖';
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
float: left;
|
||||
margin-right: 5px;
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
#mocha .test pre.error {
|
||||
color: #c00;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* (1): approximate for browsers not supporting calc
|
||||
* (2): 42 = 2*15 + 2*10 + 2*1 (padding + margin + border)
|
||||
* ^^ seriously
|
||||
*/
|
||||
#mocha .test pre {
|
||||
display: block;
|
||||
float: left;
|
||||
clear: left;
|
||||
font: 12px/1.5 monaco, monospace;
|
||||
margin: 5px;
|
||||
padding: 15px;
|
||||
border: 1px solid #eee;
|
||||
max-width: 85%; /*(1)*/
|
||||
max-width: calc(100% - 42px); /*(2)*/
|
||||
word-wrap: break-word;
|
||||
border-bottom-color: #ddd;
|
||||
-webkit-border-radius: 3px;
|
||||
-webkit-box-shadow: 0 1px 3px #eee;
|
||||
-moz-border-radius: 3px;
|
||||
-moz-box-shadow: 0 1px 3px #eee;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#mocha .test h2 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#mocha .test a.replay {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 0;
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
display: block;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
background: #eee;
|
||||
font-size: 15px;
|
||||
-moz-border-radius: 15px;
|
||||
border-radius: 15px;
|
||||
-webkit-transition: opacity 200ms;
|
||||
-moz-transition: opacity 200ms;
|
||||
transition: opacity 200ms;
|
||||
opacity: 0.3;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
#mocha .test:hover a.replay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#mocha-report.pass .test.fail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#mocha-report.fail .test.pass {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#mocha-report.pending .test.pass,
|
||||
#mocha-report.pending .test.fail {
|
||||
display: none;
|
||||
}
|
||||
#mocha-report.pending .test.pass.pending {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#mocha-error {
|
||||
color: #c00;
|
||||
font-size: 1.5em;
|
||||
font-weight: 100;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#mocha-stats {
|
||||
position: fixed;
|
||||
top: 15px;
|
||||
right: 10px;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
color: #888;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#mocha-stats .progress {
|
||||
float: right;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
#mocha-stats em {
|
||||
color: black;
|
||||
}
|
||||
|
||||
#mocha-stats a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#mocha-stats a:hover {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
#mocha-stats li {
|
||||
display: inline-block;
|
||||
margin: 0 5px;
|
||||
list-style: none;
|
||||
padding-top: 11px;
|
||||
}
|
||||
|
||||
#mocha-stats canvas {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
#mocha code .comment { color: #ddd; }
|
||||
#mocha code .init { color: #2f6fad; }
|
||||
#mocha code .string { color: #5890ad; }
|
||||
#mocha code .keyword { color: #8a6343; }
|
||||
#mocha code .number { color: #2f6fad; }
|
||||
|
||||
@media screen and (max-device-width: 480px) {
|
||||
#mocha {
|
||||
margin: 60px 0px;
|
||||
}
|
||||
|
||||
#mocha #stats {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,21 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>LoopBack Mocha Tests</title>
|
||||
<link rel="stylesheet" href="mocha.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="mocha"></div>
|
||||
<script src="chai.js"></script>
|
||||
<script src="mocha.js"></script>
|
||||
<script>
|
||||
mocha.setup('bdd');
|
||||
</script>
|
||||
<script src="tests.js"></script>
|
||||
<script>
|
||||
mocha.checkLeaks();
|
||||
mocha.globals([]);
|
||||
mocha.run();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -262,127 +262,61 @@ describe('Model', function() {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remote Methods', function(){
|
||||
beforeEach(function () {
|
||||
User.login = function (username, password, fn) {
|
||||
if(username === 'foo' && password === 'bar') {
|
||||
fn(null, 123);
|
||||
} else {
|
||||
throw new Error('bad username and password!');
|
||||
|
||||
if(loopback.isServer) {
|
||||
describe('Remote Methods', function(){
|
||||
beforeEach(function () {
|
||||
User.login = function (username, password, fn) {
|
||||
if(username === 'foo' && password === 'bar') {
|
||||
fn(null, 123);
|
||||
} else {
|
||||
throw new Error('bad username and password!');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loopback.remoteMethod(
|
||||
User.login,
|
||||
{
|
||||
accepts: [
|
||||
{arg: 'username', type: 'string', required: true},
|
||||
{arg: 'password', type: 'string', required: true}
|
||||
],
|
||||
returns: {arg: 'sessionId', type: 'any', root: true},
|
||||
http: {path: '/sign-in', verb: 'get'}
|
||||
}
|
||||
);
|
||||
loopback.remoteMethod(
|
||||
User.login,
|
||||
{
|
||||
accepts: [
|
||||
{arg: 'username', type: 'string', required: true},
|
||||
{arg: 'password', type: 'string', required: true}
|
||||
],
|
||||
returns: {arg: 'sessionId', type: 'any', root: true},
|
||||
http: {path: '/sign-in', verb: 'get'}
|
||||
}
|
||||
);
|
||||
|
||||
app.use(loopback.rest());
|
||||
app.model(User);
|
||||
});
|
||||
app.use(loopback.rest());
|
||||
app.model(User);
|
||||
});
|
||||
|
||||
describe('Example Remote Method', function () {
|
||||
it('Call the method using HTTP / REST', function(done) {
|
||||
request(app)
|
||||
.get('/users/sign-in?username=foo&password=bar')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.end(function(err, res){
|
||||
if(err) return done(err);
|
||||
assert.equal(res.body, 123);
|
||||
done();
|
||||
});
|
||||
});
|
||||
describe('Example Remote Method', function () {
|
||||
it('Call the method using HTTP / REST', function(done) {
|
||||
request(app)
|
||||
.get('/users/sign-in?username=foo&password=bar')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.end(function(err, res){
|
||||
if(err) return done(err);
|
||||
assert.equal(res.body, 123);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('Converts null result of findById to 404 Not Found', function(done) {
|
||||
request(app)
|
||||
.get('/users/not-found')
|
||||
.expect(404)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model.beforeRemote(name, fn)', function(){
|
||||
it('Run a function before a remote method is called by a client', function(done) {
|
||||
var hookCalled = false;
|
||||
|
||||
User.beforeRemote('create', function(ctx, user, next) {
|
||||
hookCalled = true;
|
||||
next();
|
||||
it('Converts null result of findById to 404 Not Found', function(done) {
|
||||
request(app)
|
||||
.get('/users/not-found')
|
||||
.expect(404)
|
||||
.end(done);
|
||||
});
|
||||
|
||||
// invoke save
|
||||
request(app)
|
||||
.post('/users')
|
||||
.send({data: {first: 'foo', last: 'bar'}})
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
if(err) return done(err);
|
||||
assert(hookCalled, 'hook wasnt called');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model.afterRemote(name, fn)', function(){
|
||||
it('Run a function after a remote method is called by a client', function(done) {
|
||||
var beforeCalled = false;
|
||||
var afterCalled = false;
|
||||
|
||||
User.beforeRemote('create', function(ctx, user, next) {
|
||||
assert(!afterCalled);
|
||||
beforeCalled = true;
|
||||
next();
|
||||
});
|
||||
User.afterRemote('create', function(ctx, user, next) {
|
||||
assert(beforeCalled);
|
||||
afterCalled = true;
|
||||
next();
|
||||
});
|
||||
|
||||
// invoke save
|
||||
request(app)
|
||||
.post('/users')
|
||||
.send({data: {first: 'foo', last: 'bar'}})
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
if(err) return done(err);
|
||||
assert(beforeCalled, 'before hook was not called');
|
||||
assert(afterCalled, 'after hook was not called');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remote Method invoking context', function () {
|
||||
// describe('ctx.user', function() {
|
||||
// it("The remote user model calling the method remotely", function(done) {
|
||||
// done(new Error('test not implemented'));
|
||||
// });
|
||||
// });
|
||||
|
||||
describe('ctx.req', function() {
|
||||
it("The express ServerRequest object", function(done) {
|
||||
describe('Model.beforeRemote(name, fn)', function(){
|
||||
it('Run a function before a remote method is called by a client', function(done) {
|
||||
var hookCalled = false;
|
||||
|
||||
|
||||
User.beforeRemote('create', function(ctx, user, next) {
|
||||
hookCalled = true;
|
||||
assert(ctx.req);
|
||||
assert(ctx.req.url);
|
||||
assert(ctx.req.method);
|
||||
assert(ctx.res);
|
||||
assert(ctx.res.write);
|
||||
assert(ctx.res.end);
|
||||
next();
|
||||
});
|
||||
|
||||
|
@ -394,24 +328,25 @@ describe('Model', function() {
|
|||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
if(err) return done(err);
|
||||
assert(hookCalled);
|
||||
assert(hookCalled, 'hook wasnt called');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ctx.res', function() {
|
||||
it("The express ServerResponse object", function(done) {
|
||||
var hookCalled = false;
|
||||
|
||||
|
||||
describe('Model.afterRemote(name, fn)', function(){
|
||||
it('Run a function after a remote method is called by a client', function(done) {
|
||||
var beforeCalled = false;
|
||||
var afterCalled = false;
|
||||
|
||||
User.beforeRemote('create', function(ctx, user, next) {
|
||||
hookCalled = true;
|
||||
assert(ctx.req);
|
||||
assert(ctx.req.url);
|
||||
assert(ctx.req.method);
|
||||
assert(ctx.res);
|
||||
assert(ctx.res.write);
|
||||
assert(ctx.res.end);
|
||||
assert(!afterCalled);
|
||||
beforeCalled = true;
|
||||
next();
|
||||
});
|
||||
User.afterRemote('create', function(ctx, user, next) {
|
||||
assert(beforeCalled);
|
||||
afterCalled = true;
|
||||
next();
|
||||
});
|
||||
|
||||
|
@ -423,7 +358,8 @@ describe('Model', function() {
|
|||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
if(err) return done(err);
|
||||
assert(hookCalled);
|
||||
assert(beforeCalled, 'before hook was not called');
|
||||
assert(afterCalled, 'after hook was not called');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -24,13 +24,15 @@ beforeEach(function () {
|
|||
connector: loopback.Memory
|
||||
});
|
||||
|
||||
loopback.setDefaultDataSourceForType('mail', {
|
||||
connector: loopback.Mail,
|
||||
transports: [
|
||||
{type: 'STUB'}
|
||||
]
|
||||
});
|
||||
|
||||
if(loopback.isServer) {
|
||||
loopback.setDefaultDataSourceForType('mail', {
|
||||
connector: loopback.Mail,
|
||||
transports: [
|
||||
{type: 'STUB'}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// auto attach data sources to models
|
||||
loopback.autoAttach();
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue