mirror of
https://repo.dactyloidae.xyz/Dactyloidae/UXP.git
synced 2026-09-05 15:28:39 +09:00
import FIREFOX_52_6_0esr_RELEASE from mozilla-esr52 hg repo
This commit is contained in:
commit
dcd9973243
150858 changed files with 23884658 additions and 0 deletions
358
testing/modules/tests/xpcshell/test_assert.js
Normal file
358
testing/modules/tests/xpcshell/test_assert.js
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
// Test cases borrowed and adapted from:
|
||||
// https://github.com/joyent/node/blob/6101eb184db77d0b11eb96e48744e57ecce4b73d/test/simple/test-assert.js
|
||||
// MIT license: http://opensource.org/licenses/MIT
|
||||
|
||||
function run_test() {
|
||||
let ns = {};
|
||||
Components.utils.import("resource://testing-common/Assert.jsm", ns);
|
||||
let assert = new ns.Assert();
|
||||
|
||||
function makeBlock(f, ...args) {
|
||||
return function() {
|
||||
return f.apply(assert, args);
|
||||
};
|
||||
}
|
||||
|
||||
function protoCtrChain(o) {
|
||||
let result = [];
|
||||
while (o = o.__proto__) {
|
||||
result.push(o.constructor);
|
||||
}
|
||||
return result.join();
|
||||
}
|
||||
|
||||
function indirectInstanceOf(obj, cls) {
|
||||
if (obj instanceof cls) {
|
||||
return true;
|
||||
}
|
||||
let clsChain = protoCtrChain(cls.prototype);
|
||||
let objChain = protoCtrChain(obj);
|
||||
return objChain.slice(-clsChain.length) === clsChain;
|
||||
};
|
||||
|
||||
assert.ok(indirectInstanceOf(ns.Assert.AssertionError.prototype, Error),
|
||||
"Assert.AssertionError instanceof Error");
|
||||
|
||||
assert.throws(makeBlock(assert.ok, false),
|
||||
ns.Assert.AssertionError, "ok(false)");
|
||||
|
||||
assert.ok(true, "ok(true)");
|
||||
|
||||
assert.ok("test", "ok('test')");
|
||||
|
||||
assert.throws(makeBlock(assert.equal, true, false), ns.Assert.AssertionError, "equal");
|
||||
|
||||
assert.equal(null, null, "equal");
|
||||
|
||||
assert.equal(undefined, undefined, "equal");
|
||||
|
||||
assert.equal(null, undefined, "equal");
|
||||
|
||||
assert.equal(true, true, "equal");
|
||||
|
||||
assert.notEqual(true, false, "notEqual");
|
||||
|
||||
assert.throws(makeBlock(assert.notEqual, true, true),
|
||||
ns.Assert.AssertionError, "notEqual");
|
||||
|
||||
assert.throws(makeBlock(assert.strictEqual, 2, "2"),
|
||||
ns.Assert.AssertionError, "strictEqual");
|
||||
|
||||
assert.throws(makeBlock(assert.strictEqual, null, undefined),
|
||||
ns.Assert.AssertionError, "strictEqual");
|
||||
|
||||
assert.notStrictEqual(2, "2", "notStrictEqual");
|
||||
|
||||
// deepEquals joy!
|
||||
// 7.2
|
||||
assert.deepEqual(new Date(2000, 3, 14), new Date(2000, 3, 14), "deepEqual date");
|
||||
assert.deepEqual(new Date(NaN), new Date(NaN), "deepEqual invalid dates");
|
||||
|
||||
assert.throws(makeBlock(assert.deepEqual, new Date(), new Date(2000, 3, 14)),
|
||||
ns.Assert.AssertionError,
|
||||
"deepEqual date");
|
||||
|
||||
// 7.3
|
||||
assert.deepEqual(/a/, /a/);
|
||||
assert.deepEqual(/a/g, /a/g);
|
||||
assert.deepEqual(/a/i, /a/i);
|
||||
assert.deepEqual(/a/m, /a/m);
|
||||
assert.deepEqual(/a/igm, /a/igm);
|
||||
assert.throws(makeBlock(assert.deepEqual, /ab/, /a/));
|
||||
assert.throws(makeBlock(assert.deepEqual, /a/g, /a/));
|
||||
assert.throws(makeBlock(assert.deepEqual, /a/i, /a/));
|
||||
assert.throws(makeBlock(assert.deepEqual, /a/m, /a/));
|
||||
assert.throws(makeBlock(assert.deepEqual, /a/igm, /a/im));
|
||||
|
||||
let re1 = /a/;
|
||||
re1.lastIndex = 3;
|
||||
assert.throws(makeBlock(assert.deepEqual, re1, /a/));
|
||||
|
||||
// 7.4
|
||||
assert.deepEqual(4, "4", "deepEqual == check");
|
||||
assert.deepEqual(true, 1, "deepEqual == check");
|
||||
assert.throws(makeBlock(assert.deepEqual, 4, "5"),
|
||||
ns.Assert.AssertionError,
|
||||
"deepEqual == check");
|
||||
|
||||
// 7.5
|
||||
// having the same number of owned properties && the same set of keys
|
||||
assert.deepEqual({a: 4}, {a: 4});
|
||||
assert.deepEqual({a: 4, b: "2"}, {a: 4, b: "2"});
|
||||
assert.deepEqual([4], ["4"]);
|
||||
assert.throws(makeBlock(assert.deepEqual, {a: 4}, {a: 4, b: true}),
|
||||
ns.Assert.AssertionError);
|
||||
assert.deepEqual(["a"], {0: "a"});
|
||||
|
||||
let a1 = [1, 2, 3];
|
||||
let a2 = [1, 2, 3];
|
||||
a1.a = "test";
|
||||
a1.b = true;
|
||||
a2.b = true;
|
||||
a2.a = "test";
|
||||
assert.throws(makeBlock(assert.deepEqual, Object.keys(a1), Object.keys(a2)),
|
||||
ns.Assert.AssertionError);
|
||||
assert.deepEqual(a1, a2);
|
||||
|
||||
let nbRoot = {
|
||||
toString: function() { return this.first + " " + this.last; }
|
||||
};
|
||||
|
||||
function nameBuilder(first, last) {
|
||||
this.first = first;
|
||||
this.last = last;
|
||||
return this;
|
||||
}
|
||||
nameBuilder.prototype = nbRoot;
|
||||
|
||||
function nameBuilder2(first, last) {
|
||||
this.first = first;
|
||||
this.last = last;
|
||||
return this;
|
||||
}
|
||||
nameBuilder2.prototype = nbRoot;
|
||||
|
||||
let nb1 = new nameBuilder("Ryan", "Dahl");
|
||||
let nb2 = new nameBuilder2("Ryan", "Dahl");
|
||||
|
||||
assert.deepEqual(nb1, nb2);
|
||||
|
||||
nameBuilder2.prototype = Object;
|
||||
nb2 = new nameBuilder2("Ryan", "Dahl");
|
||||
assert.throws(makeBlock(assert.deepEqual, nb1, nb2), ns.Assert.AssertionError);
|
||||
|
||||
// String literal + object
|
||||
assert.throws(makeBlock(assert.deepEqual, "a", {}), ns.Assert.AssertionError);
|
||||
|
||||
// Testing the throwing
|
||||
function thrower(errorConstructor) {
|
||||
throw new errorConstructor("test");
|
||||
}
|
||||
let aethrow = makeBlock(thrower, ns.Assert.AssertionError);
|
||||
aethrow = makeBlock(thrower, ns.Assert.AssertionError);
|
||||
|
||||
// the basic calls work
|
||||
assert.throws(makeBlock(thrower, ns.Assert.AssertionError),
|
||||
ns.Assert.AssertionError, "message");
|
||||
assert.throws(makeBlock(thrower, ns.Assert.AssertionError), ns.Assert.AssertionError);
|
||||
assert.throws(makeBlock(thrower, ns.Assert.AssertionError));
|
||||
|
||||
// if not passing an error, catch all.
|
||||
assert.throws(makeBlock(thrower, TypeError));
|
||||
|
||||
// when passing a type, only catch errors of the appropriate type
|
||||
let threw = false;
|
||||
try {
|
||||
assert.throws(makeBlock(thrower, TypeError), ns.Assert.AssertionError);
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
assert.ok(e instanceof TypeError, "type");
|
||||
}
|
||||
assert.equal(true, threw,
|
||||
"Assert.throws with an explicit error is eating extra errors",
|
||||
ns.Assert.AssertionError);
|
||||
threw = false;
|
||||
|
||||
function ifError(err) {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
assert.throws(function() {
|
||||
ifError(new Error("test error"));
|
||||
});
|
||||
|
||||
// make sure that validating using constructor really works
|
||||
threw = false;
|
||||
try {
|
||||
assert.throws(
|
||||
function() {
|
||||
throw ({});
|
||||
},
|
||||
Array
|
||||
);
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
}
|
||||
assert.ok(threw, "wrong constructor validation");
|
||||
|
||||
// use a RegExp to validate error message
|
||||
assert.throws(makeBlock(thrower, TypeError), /test/);
|
||||
|
||||
// use a fn to validate error object
|
||||
assert.throws(makeBlock(thrower, TypeError), function(err) {
|
||||
if ((err instanceof TypeError) && /test/.test(err)) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
function testAssertionMessage(actual, expected) {
|
||||
try {
|
||||
assert.equal(actual, "");
|
||||
} catch (e) {
|
||||
assert.equal(e.toString(),
|
||||
["AssertionError:", expected, "==", '""'].join(" "));
|
||||
}
|
||||
}
|
||||
testAssertionMessage(undefined, '"undefined"');
|
||||
testAssertionMessage(null, "null");
|
||||
testAssertionMessage(true, "true");
|
||||
testAssertionMessage(false, "false");
|
||||
testAssertionMessage(0, "0");
|
||||
testAssertionMessage(100, "100");
|
||||
testAssertionMessage(NaN, '"NaN"');
|
||||
testAssertionMessage(Infinity, '"Infinity"');
|
||||
testAssertionMessage(-Infinity, '"-Infinity"');
|
||||
testAssertionMessage("", '""');
|
||||
testAssertionMessage("foo", '"foo"');
|
||||
testAssertionMessage([], "[]");
|
||||
testAssertionMessage([1, 2, 3], "[1,2,3]");
|
||||
testAssertionMessage(/a/, '"/a/"');
|
||||
testAssertionMessage(/abc/gim, '"/abc/gim"');
|
||||
testAssertionMessage(function f() {}, '"function f() {}"');
|
||||
testAssertionMessage({}, "{}");
|
||||
testAssertionMessage({a: undefined, b: null}, '{"a":"undefined","b":null}');
|
||||
testAssertionMessage({a: NaN, b: Infinity, c: -Infinity},
|
||||
'{"a":"NaN","b":"Infinity","c":"-Infinity"}');
|
||||
|
||||
// https://github.com/joyent/node/issues/2893
|
||||
try {
|
||||
assert.throws(function () {
|
||||
ifError(null);
|
||||
});
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
assert.equal(e.message, "Missing expected exception..");
|
||||
}
|
||||
assert.ok(threw);
|
||||
|
||||
// https://github.com/joyent/node/issues/5292
|
||||
try {
|
||||
assert.equal(1, 2);
|
||||
} catch (e) {
|
||||
assert.equal(e.toString().split("\n")[0], "AssertionError: 1 == 2")
|
||||
}
|
||||
|
||||
try {
|
||||
assert.equal(1, 2, "oh no");
|
||||
} catch (e) {
|
||||
assert.equal(e.toString().split("\n")[0], "AssertionError: oh no - 1 == 2")
|
||||
}
|
||||
|
||||
// Test XPCShell-test integration:
|
||||
ok(true, "OK, this went well");
|
||||
deepEqual(/a/g, /a/g, "deep equal should work on RegExp");
|
||||
deepEqual(/a/igm, /a/igm, "deep equal should work on RegExp");
|
||||
deepEqual({a: 4, b: "1"}, {b: "1", a: 4}, "deep equal should work on regular Object");
|
||||
deepEqual(a1, a2, "deep equal should work on Array with Object properties");
|
||||
|
||||
// Test robustness of reporting:
|
||||
equal(new ns.Assert.AssertionError({
|
||||
actual: {
|
||||
toJSON: function() {
|
||||
throw "bam!";
|
||||
}
|
||||
},
|
||||
expected: "foo",
|
||||
operator: "="
|
||||
}).message, "[object Object] = \"foo\"");
|
||||
|
||||
let message;
|
||||
assert.greater(3, 2);
|
||||
try {
|
||||
assert.greater(2, 2);
|
||||
} catch(e) {
|
||||
message = e.toString().split("\n")[0];
|
||||
}
|
||||
assert.equal(message, "AssertionError: 2 > 2");
|
||||
|
||||
assert.greaterOrEqual(2, 2);
|
||||
try {
|
||||
assert.greaterOrEqual(1, 2);
|
||||
} catch(e) {
|
||||
message = e.toString().split("\n")[0];
|
||||
}
|
||||
assert.equal(message, "AssertionError: 1 >= 2");
|
||||
|
||||
assert.less(1, 2);
|
||||
try {
|
||||
assert.less(2, 2);
|
||||
} catch(e) {
|
||||
message = e.toString().split("\n")[0];
|
||||
}
|
||||
assert.equal(message, "AssertionError: 2 < 2");
|
||||
|
||||
assert.lessOrEqual(2, 2);
|
||||
try {
|
||||
assert.lessOrEqual(2, 1);
|
||||
} catch(e) {
|
||||
message = e.toString().split("\n")[0];
|
||||
}
|
||||
assert.equal(message, "AssertionError: 2 <= 1");
|
||||
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
add_task(function* test_rejects() {
|
||||
let ns = {};
|
||||
Components.utils.import("resource://testing-common/Assert.jsm", ns);
|
||||
let assert = new ns.Assert();
|
||||
|
||||
// A helper function to test failures.
|
||||
function* checkRejectsFails(err, expected) {
|
||||
try {
|
||||
yield assert.rejects(Promise.reject(err), expected);
|
||||
ok(false, "should have thrown");
|
||||
} catch(ex) {
|
||||
deepEqual(ex, err, "Assert.rejects threw the original unexpected error");
|
||||
}
|
||||
}
|
||||
|
||||
// A "throwable" error that's not an actual Error().
|
||||
let SomeErrorLikeThing = function() {};
|
||||
|
||||
// The actual tests...
|
||||
// No "expected" or "message" values supplied.
|
||||
yield assert.rejects(Promise.reject(new Error("oh no")));
|
||||
yield assert.rejects(Promise.reject("oh no"));
|
||||
|
||||
// An explicit error object:
|
||||
// An instance to check against.
|
||||
yield assert.rejects(Promise.reject(new Error("oh no")), Error, "rejected");
|
||||
// A regex to match against the message.
|
||||
yield assert.rejects(Promise.reject(new Error("oh no")), /oh no/, "rejected");
|
||||
|
||||
// Failure cases:
|
||||
// An instance to check against that doesn't match.
|
||||
yield checkRejectsFails(new Error("something else"), SomeErrorLikeThing);
|
||||
// A regex that doesn't match.
|
||||
yield checkRejectsFails(new Error("something else"), /oh no/);
|
||||
|
||||
// Check simple string messages.
|
||||
yield assert.rejects(Promise.reject("oh no"), /oh no/, "rejected");
|
||||
// Wrong message.
|
||||
yield checkRejectsFails("something else", /oh no/);
|
||||
});
|
||||
51
testing/modules/tests/xpcshell/test_mockRegistrar.js
Normal file
51
testing/modules/tests/xpcshell/test_mockRegistrar.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://testing-common/MockRegistrar.jsm");
|
||||
|
||||
function userInfo(username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
userInfo.prototype = {
|
||||
fullname: "fullname",
|
||||
emailAddress: "emailAddress",
|
||||
domain: "domain",
|
||||
QueryInterface: XPCOMUtils.generateQI([Ci.nsIUserInfo]),
|
||||
};
|
||||
|
||||
function run_test () {
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
add_test(function test_register() {
|
||||
let localUserInfo = {
|
||||
fullname: "fullname",
|
||||
username: "localusername",
|
||||
emailAddress: "emailAddress",
|
||||
domain: "domain",
|
||||
QueryInterface: XPCOMUtils.generateQI([Ci.nsIUserInfo]),
|
||||
};
|
||||
|
||||
let userInfoCID = MockRegistrar.register("@mozilla.org/userinfo;1", localUserInfo);
|
||||
Assert.equal(Cc["@mozilla.org/userinfo;1"].createInstance(Ci.nsIUserInfo).username, "localusername");
|
||||
run_next_test();
|
||||
});
|
||||
|
||||
add_test(function test_register_with_arguments() {
|
||||
let userInfoCID = MockRegistrar.register("@mozilla.org/userinfo;1", userInfo, ["username"]);
|
||||
Assert.equal(Cc["@mozilla.org/userinfo;1"].createInstance(Ci.nsIUserInfo).username, "username");
|
||||
run_next_test();
|
||||
});
|
||||
|
||||
add_test(function test_register_twice() {
|
||||
let userInfoCID = MockRegistrar.register("@mozilla.org/userinfo;1", userInfo, ["originalname"]);
|
||||
Assert.equal(Cc["@mozilla.org/userinfo;1"].createInstance(Ci.nsIUserInfo).username, "originalname");
|
||||
|
||||
let newUserInfoCID = MockRegistrar.register("@mozilla.org/userinfo;1", userInfo, ["newname"]);
|
||||
Assert.equal(Cc["@mozilla.org/userinfo;1"].createInstance(Ci.nsIUserInfo).username, "newname");
|
||||
run_next_test();
|
||||
});
|
||||
145
testing/modules/tests/xpcshell/test_structuredlog.js
Normal file
145
testing/modules/tests/xpcshell/test_structuredlog.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
function run_test () {
|
||||
Components.utils.import("resource://testing-common/StructuredLog.jsm");
|
||||
|
||||
let testBuffer = [];
|
||||
|
||||
let appendBuffer = function (msg) {
|
||||
testBuffer.push(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
let assertLastMsg = function (refData) {
|
||||
// Check all fields in refData agree with those in the
|
||||
// last message logged, and pop that message.
|
||||
let lastMsg = JSON.parse(testBuffer.pop());
|
||||
for (let field in refData) {
|
||||
deepEqual(lastMsg[field], refData[field]);
|
||||
}
|
||||
// The logger should always set the source to the logger name.
|
||||
equal(lastMsg.source, "test_log");
|
||||
// The source_file field is always set by the mutator function.
|
||||
equal(lastMsg.source_file, "test_structuredlog.js");
|
||||
}
|
||||
|
||||
let addFileName = function (data) {
|
||||
data.source_file = "test_structuredlog.js";
|
||||
}
|
||||
|
||||
let logger = new StructuredLogger("test_log", appendBuffer, [addFileName]);
|
||||
|
||||
// Test unstructured logging
|
||||
logger.info("Test message");
|
||||
assertLastMsg({
|
||||
action: "log",
|
||||
message: "Test message",
|
||||
level: "INFO",
|
||||
});
|
||||
|
||||
logger.info("Test message",
|
||||
extra={foo: "bar"});
|
||||
assertLastMsg({
|
||||
action: "log",
|
||||
message: "Test message",
|
||||
level: "INFO",
|
||||
extra: {foo: "bar"},
|
||||
});
|
||||
|
||||
// Test end / start actions
|
||||
logger.testStart("aTest");
|
||||
assertLastMsg({
|
||||
test: "aTest",
|
||||
action: "test_start",
|
||||
});
|
||||
|
||||
logger.testEnd("aTest", "OK");
|
||||
assertLastMsg({
|
||||
test: "aTest",
|
||||
action: "test_end",
|
||||
status: "OK"
|
||||
});
|
||||
|
||||
// A failed test populates the "expected" field.
|
||||
logger.testStart("aTest");
|
||||
logger.testEnd("aTest", "FAIL", "PASS");
|
||||
assertLastMsg({
|
||||
action: "test_end",
|
||||
test: "aTest",
|
||||
status: "FAIL",
|
||||
expected: "PASS"
|
||||
});
|
||||
|
||||
// A failed test populates the "expected" field.
|
||||
logger.testStart("aTest");
|
||||
logger.testEnd("aTest", "FAIL", "PASS", null, "Many\nlines\nof\nstack\n");
|
||||
assertLastMsg({
|
||||
action: "test_end",
|
||||
test: "aTest",
|
||||
status: "FAIL",
|
||||
expected: "PASS",
|
||||
stack: "Many\nlines\nof\nstack\n"
|
||||
});
|
||||
|
||||
// Skipped tests don't log failures
|
||||
logger.testStart("aTest");
|
||||
logger.testEnd("aTest", "SKIP", "PASS");
|
||||
ok(!JSON.parse(testBuffer[testBuffer.length - 1]).hasOwnProperty("expected"));
|
||||
assertLastMsg({
|
||||
action: "test_end",
|
||||
test: "aTest",
|
||||
status: "SKIP"
|
||||
});
|
||||
|
||||
logger.testStatus("aTest", "foo", "PASS", "PASS", "Passed test");
|
||||
ok(!JSON.parse(testBuffer[testBuffer.length - 1]).hasOwnProperty("expected"));
|
||||
assertLastMsg({
|
||||
action: "test_status",
|
||||
test: "aTest",
|
||||
subtest: "foo",
|
||||
status: "PASS",
|
||||
message: "Passed test"
|
||||
});
|
||||
|
||||
logger.testStatus("aTest", "bar", "FAIL");
|
||||
assertLastMsg({
|
||||
action: "test_status",
|
||||
test: "aTest",
|
||||
subtest: "bar",
|
||||
status: "FAIL",
|
||||
expected: "PASS"
|
||||
});
|
||||
|
||||
logger.testStatus("aTest", "bar", "FAIL", "PASS", null,
|
||||
"Many\nlines\nof\nstack\n");
|
||||
assertLastMsg({
|
||||
action: "test_status",
|
||||
test: "aTest",
|
||||
subtest: "bar",
|
||||
status: "FAIL",
|
||||
expected: "PASS",
|
||||
stack: "Many\nlines\nof\nstack\n"
|
||||
});
|
||||
|
||||
// Skipped tests don't log failures
|
||||
logger.testStatus("aTest", "baz", "SKIP");
|
||||
ok(!JSON.parse(testBuffer[testBuffer.length - 1]).hasOwnProperty("expected"));
|
||||
assertLastMsg({
|
||||
action: "test_status",
|
||||
test: "aTest",
|
||||
subtest: "baz",
|
||||
status: "SKIP"
|
||||
});
|
||||
|
||||
// Suite start and end messages.
|
||||
logger.suiteStart(["aTest"]);
|
||||
assertLastMsg({
|
||||
action: "suite_start",
|
||||
tests: ["aTest"],
|
||||
});
|
||||
|
||||
logger.suiteEnd();
|
||||
assertLastMsg({
|
||||
action: "suite_end",
|
||||
});
|
||||
}
|
||||
8
testing/modules/tests/xpcshell/xpcshell.ini
Normal file
8
testing/modules/tests/xpcshell/xpcshell.ini
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[DEFAULT]
|
||||
head =
|
||||
tail =
|
||||
skip-if = toolkit == 'android' || toolkit == 'gonk'
|
||||
|
||||
[test_assert.js]
|
||||
[test_mockRegistrar.js]
|
||||
[test_structuredlog.js]
|
||||
Loading…
Add table
Add a link
Reference in a new issue