mirror of
https://repo.dactyloidae.xyz/Dactyloidae/UXP.git
synced 2026-10-23 07:37:29 +09:00
Bug 1430173 - Add Timer Rounding tests backported from -central to -esr. r=baku, a=RyanVM
MozReview-Commit-ID: Jl4WZAamgrI --HG-- extra : transplant_source : E%DC%91lU%1C%A4l%2C%C8%23PCz%EB%F2%81%25%1F%90
This commit is contained in:
parent
c66f2e1613
commit
53a2f1007f
7 changed files with 390 additions and 0 deletions
2
dom/tests/browser/browser.ini
Normal file → Executable file
2
dom/tests/browser/browser.ini
Normal file → Executable file
|
|
@ -8,6 +8,7 @@ support-files =
|
||||||
worker_bug1004814.js
|
worker_bug1004814.js
|
||||||
geo_leak_test.html
|
geo_leak_test.html
|
||||||
dummy.html
|
dummy.html
|
||||||
|
file_workerPerformance.js
|
||||||
test_largeAllocation.html
|
test_largeAllocation.html
|
||||||
test_largeAllocation.html^headers^
|
test_largeAllocation.html^headers^
|
||||||
!/dom/tests/mochitest/geolocation/network_geolocation.sjs
|
!/dom/tests/mochitest/geolocation/network_geolocation.sjs
|
||||||
|
|
@ -44,3 +45,4 @@ support-files =
|
||||||
support-files =
|
support-files =
|
||||||
test_new_window_from_content_child.html
|
test_new_window_from_content_child.html
|
||||||
[browser_xhr_sandbox.js]
|
[browser_xhr_sandbox.js]
|
||||||
|
[browser_performanceAPI.js]
|
||||||
|
|
|
||||||
139
dom/tests/browser/browser_performanceAPI.js
Executable file
139
dom/tests/browser/browser_performanceAPI.js
Executable file
|
|
@ -0,0 +1,139 @@
|
||||||
|
/**
|
||||||
|
* Bug 1369303 - A test for making sure that performance APIs have been correctly
|
||||||
|
* spoofed or disabled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TEST_PATH = "http://example.net/browser/" +
|
||||||
|
"dom/tests/browser/";
|
||||||
|
|
||||||
|
const PERFORMANCE_TIMINGS = [
|
||||||
|
"navigationStart",
|
||||||
|
"unloadEventStart",
|
||||||
|
"unloadEventEnd",
|
||||||
|
"redirectStart",
|
||||||
|
"redirectEnd",
|
||||||
|
"fetchStart",
|
||||||
|
"domainLookupStart",
|
||||||
|
"domainLookupEnd",
|
||||||
|
"connectStart",
|
||||||
|
"connectEnd",
|
||||||
|
"requestStart",
|
||||||
|
"responseStart",
|
||||||
|
"responseEnd",
|
||||||
|
"domLoading",
|
||||||
|
"domInteractive",
|
||||||
|
"domContentLoadedEventStart",
|
||||||
|
"domContentLoadedEventEnd",
|
||||||
|
"domComplete",
|
||||||
|
"loadEventStart",
|
||||||
|
"loadEventEnd",
|
||||||
|
];
|
||||||
|
|
||||||
|
let isRounded = (x, expectedPrecision) => {
|
||||||
|
let rounded = (Math.floor(x / expectedPrecision) * expectedPrecision);
|
||||||
|
// First we do the perfectly normal check that should work just fine
|
||||||
|
if (rounded === x || x === 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// When we're diving by non-whole numbers, we may not get perfect
|
||||||
|
// multiplication/division because of floating points
|
||||||
|
if (Math.abs(rounded - x + expectedPrecision) < .0000001) {
|
||||||
|
return true;
|
||||||
|
} else if (Math.abs(rounded - x) < .0000001) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then we handle the case where you're sub-millisecond and the timer is not
|
||||||
|
// We check that the timer is not sub-millisecond by assuming it is not if it
|
||||||
|
// returns an even number of milliseconds
|
||||||
|
if (expectedPrecision < 1 && Math.round(x) == x) {
|
||||||
|
if (Math.round(rounded) == x) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok(false, "Looming Test Failure, Additional Debugging Info: Expected Precision: " + expectedPrecision + " Measured Value: " + x +
|
||||||
|
" Rounded Vaue: " + rounded + " Fuzzy1: " + Math.abs(rounded - x + expectedPrecision) +
|
||||||
|
" Fuzzy 2: " + Math.abs(rounded - x));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ================================================================================================
|
||||||
|
// ================================================================================================
|
||||||
|
add_task(function* () {
|
||||||
|
let tab = yield BrowserTestUtils.openNewForegroundTab(
|
||||||
|
gBrowser, TEST_PATH + "dummy.html");
|
||||||
|
|
||||||
|
yield ContentTask.spawn(tab.linkedBrowser, {
|
||||||
|
list: PERFORMANCE_TIMINGS,
|
||||||
|
precision: 2,
|
||||||
|
isRoundedFunc: isRounded.toString()
|
||||||
|
}, (data) => {
|
||||||
|
let timerlist = data.list;
|
||||||
|
let expectedPrecision = data.precision;
|
||||||
|
// eslint beleives that isrounded is available in this scope, but if you
|
||||||
|
// remove the assignment, you will see it is not
|
||||||
|
// eslint-disable-next-line
|
||||||
|
let isRounded = eval(data.isRoundedFunc);
|
||||||
|
|
||||||
|
// Check that whether the performance timing API is correctly spoofed.
|
||||||
|
for (let time of timerlist) {
|
||||||
|
ok(isRounded(content.performance.timing[time], expectedPrecision), `For reduceTimerPrecision(` + expectedPrecision + `), the timing(${time}) is not correctly rounded: ` + content.performance.timing[time]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to add some entries.
|
||||||
|
content.performance.mark("Test");
|
||||||
|
content.performance.mark("Test-End");
|
||||||
|
content.performance.measure("Test-Measure", "Test", "Test-End");
|
||||||
|
|
||||||
|
// Check the entries for performance.getEntries/getEntriesByType/getEntriesByName.
|
||||||
|
is(content.performance.getEntries().length, 3, "For reduceTimerPrecision, there should be 3 entries for performance.getEntries()");
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
let startTime = content.performance.getEntries()[i].startTime;
|
||||||
|
let duration = content.performance.getEntries()[i].duration;
|
||||||
|
ok(isRounded(startTime, expectedPrecision), "For reduceTimerPrecision(" + expectedPrecision + "), performance.getEntries(" + i + ").startTime is not rounded: " + startTime);
|
||||||
|
ok(isRounded(duration, expectedPrecision), "For reduceTimerPrecision(" + expectedPrecision + "), performance.getEntries(" + i + ").duration is not rounded: " + duration);
|
||||||
|
}
|
||||||
|
is(content.performance.getEntriesByType("mark").length, 2, "For reduceTimerPrecision, there should be 2 entries for performance.getEntriesByType()");
|
||||||
|
is(content.performance.getEntriesByName("Test", "mark").length, 1, "For reduceTimerPrecision, there should be 1 entry for performance.getEntriesByName()");
|
||||||
|
content.performance.clearMarks();
|
||||||
|
content.performance.clearMeasures();
|
||||||
|
content.performance.clearResourceTimings();
|
||||||
|
});
|
||||||
|
gBrowser.removeTab(tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================================================
|
||||||
|
// ================================================================================================
|
||||||
|
add_task(function*() {
|
||||||
|
let tab = yield BrowserTestUtils.openNewForegroundTab(
|
||||||
|
gBrowser, TEST_PATH + "dummy.html");
|
||||||
|
|
||||||
|
yield ContentTask.spawn(tab.linkedBrowser, {
|
||||||
|
list: PERFORMANCE_TIMINGS,
|
||||||
|
precision: 2,
|
||||||
|
isRoundedFunc: isRounded.toString()
|
||||||
|
}, (data) => {
|
||||||
|
let expectedPrecision = data.precision;
|
||||||
|
let workerCall = data.workerCall;
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let worker = new content.Worker("file_workerPerformance.js");
|
||||||
|
worker.onmessage = function(e) {
|
||||||
|
if (e.data.type == "status") {
|
||||||
|
ok(e.data.status, e.data.msg);
|
||||||
|
} else if (e.data.type == "finish") {
|
||||||
|
worker.terminate();
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
ok(false, "Unknown message type");
|
||||||
|
worker.terminate();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
worker.postMessage({precision: expectedPrecision});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
gBrowser.removeTab(tab);
|
||||||
|
});
|
||||||
65
dom/tests/browser/file_workerPerformance.js
Executable file
65
dom/tests/browser/file_workerPerformance.js
Executable file
|
|
@ -0,0 +1,65 @@
|
||||||
|
function ok(a, msg) {
|
||||||
|
postMessage({type: "status", status: !!a, msg});
|
||||||
|
}
|
||||||
|
|
||||||
|
function is(a, b, msg) {
|
||||||
|
ok(a === b, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
postMessage({type: "finish"});
|
||||||
|
}
|
||||||
|
|
||||||
|
let isRounded = (x, expectedPrecision) => {
|
||||||
|
let rounded = (Math.floor(x / expectedPrecision) * expectedPrecision);
|
||||||
|
// First we do the perfectly normal check that should work just fine
|
||||||
|
if (rounded === x || x === 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// When we're diving by non-whole numbers, we may not get perfect
|
||||||
|
// multiplication/division because of floating points
|
||||||
|
if (Math.abs(rounded - x + expectedPrecision) < .0000001) {
|
||||||
|
return true;
|
||||||
|
} else if (Math.abs(rounded - x) < .0000001) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then we handle the case where you're sub-millisecond and the timer is not
|
||||||
|
// We check that the timer is not sub-millisecond by assuming it is not if it
|
||||||
|
// returns an even number of milliseconds
|
||||||
|
if (expectedPrecision < 1 && Math.round(x) == x) {
|
||||||
|
if (Math.round(rounded) == x) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok(false, "Looming Test Failure, Additional Debugging Info: Expected Precision: " + expectedPrecision + " Measured Value: " + x +
|
||||||
|
" Rounded Vaue: " + rounded + " Fuzzy1: " + Math.abs(rounded - x + expectedPrecision) +
|
||||||
|
" Fuzzy 2: " + Math.abs(rounded - x));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function runRTPTests(expectedPrecision) {
|
||||||
|
// Try to add some entries.
|
||||||
|
performance.mark("Test");
|
||||||
|
performance.mark("Test-End");
|
||||||
|
performance.measure("Test-Measure", "Test", "Test-End");
|
||||||
|
|
||||||
|
// Check the entries in performance.getEntries/getEntriesByType/getEntriesByName.
|
||||||
|
is(performance.getEntries().length, 3, "In a worker, for reduceTimerPrecision: Incorrect number of entries for performance.getEntries() for workers: " + performance.getEntries().length);
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
let startTime = performance.getEntries()[i].startTime;
|
||||||
|
let duration = performance.getEntries()[i].duration;
|
||||||
|
ok(isRounded(startTime, expectedPrecision), "In a worker, for reduceTimerPrecision(" + expectedPrecision + "), performance.getEntries(" + i.toString() + ").startTime is not rounded: " + startTime.toString());
|
||||||
|
ok(isRounded(duration, expectedPrecision), "In a worker, for reduceTimerPrecision(" + expectedPrecision + "), performance.getEntries(" + i.toString() + ").duration is not rounded: " + duration.toString());
|
||||||
|
}
|
||||||
|
is(performance.getEntriesByType("mark").length, 2, "In a worker, for reduceTimerPrecision: Incorrect number of entries for performance.getEntriesByType() for workers: " + performance.getEntriesByType("resource").length);
|
||||||
|
is(performance.getEntriesByName("Test", "mark").length, 1, "In a worker, for reduceTimerPrecision: Incorrect number of entries for performance.getEntriesByName() for workers: " + performance.getEntriesByName("Test", "mark").length);
|
||||||
|
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = function(e) {
|
||||||
|
runRTPTests(e.data.precision);
|
||||||
|
};
|
||||||
3
dom/tests/mochitest/general/mochitest.ini
Normal file → Executable file
3
dom/tests/mochitest/general/mochitest.ini
Normal file → Executable file
|
|
@ -43,6 +43,8 @@ support-files =
|
||||||
frameStoragePrevented.html
|
frameStoragePrevented.html
|
||||||
frameStorageChrome.html
|
frameStorageChrome.html
|
||||||
frameStorageNullprincipal.sjs
|
frameStorageNullprincipal.sjs
|
||||||
|
worker_child.js
|
||||||
|
worker_grandchild.js
|
||||||
workerStorageAllowed.js
|
workerStorageAllowed.js
|
||||||
workerStoragePrevented.js
|
workerStoragePrevented.js
|
||||||
storagePermissionsUtils.js
|
storagePermissionsUtils.js
|
||||||
|
|
@ -108,6 +110,7 @@ support-files = test_offsets.js
|
||||||
[test_picture_mutations.html]
|
[test_picture_mutations.html]
|
||||||
[test_pointerPreserves3D.html]
|
[test_pointerPreserves3D.html]
|
||||||
[test_pointerPreserves3DClip.html]
|
[test_pointerPreserves3DClip.html]
|
||||||
|
[test_reduce_time_precision.html]
|
||||||
[test_resource_timing.html]
|
[test_resource_timing.html]
|
||||||
[test_resource_timing_cross_origin.html]
|
[test_resource_timing_cross_origin.html]
|
||||||
[test_resource_timing_frameset.html]
|
[test_resource_timing_frameset.html]
|
||||||
|
|
|
||||||
143
dom/tests/mochitest/general/test_reduce_time_precision.html
Executable file
143
dom/tests/mochitest/general/test_reduce_time_precision.html
Executable file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<!DOCTYPE HTML>
|
||||||
|
<html>
|
||||||
|
<!--
|
||||||
|
Tor bug
|
||||||
|
https://trac.torproject.org/projects/tor/ticket/1517
|
||||||
|
-->
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Test for Tor Bug 1517 and Mozilla Bug 1424341</title>
|
||||||
|
<script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
|
||||||
|
<script type="application/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a target="_blank" href="https://trac.torproject.org/projects/tor/ticket/1517">Tor Bug 1517</a>
|
||||||
|
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1424341">Mozilla Bug 1424341</a>
|
||||||
|
|
||||||
|
<!-- Canvas for testing 'currentTime' -->
|
||||||
|
<canvas id="test-canvas" width="100" height="100"></canvas>
|
||||||
|
|
||||||
|
<!-- The main testing script -->
|
||||||
|
<script type="application/javascript">
|
||||||
|
SimpleTest.requestFlakyTimeout("testing JS time-based fingerprinting");
|
||||||
|
|
||||||
|
// Prepare for test of AudioContext.currentTime
|
||||||
|
let audioContext = new AudioContext();
|
||||||
|
// Prepare for test of CanvasStream.currentTime
|
||||||
|
let canvas = document.getElementById("test-canvas");
|
||||||
|
let context = canvas.getContext("2d");
|
||||||
|
context.fillText("test", 20, 20);
|
||||||
|
let canvasStream = canvas.captureStream(25);
|
||||||
|
|
||||||
|
// Known ways to generate time stamps, in milliseconds
|
||||||
|
const timeStampCodes = [
|
||||||
|
"performance.now()",
|
||||||
|
"new Date().getTime()",
|
||||||
|
"new Event(\"\").timeStamp",
|
||||||
|
"new File([], \"\").lastModified",
|
||||||
|
"new File([], \"\").lastModifiedDate.getTime()",
|
||||||
|
];
|
||||||
|
// These are measured in seconds, so we need to scale them up
|
||||||
|
var timeStampCodesDOM = timeStampCodes.concat([
|
||||||
|
"audioContext.currentTime * 1000",
|
||||||
|
"canvasStream.currentTime * 1000",
|
||||||
|
]);
|
||||||
|
|
||||||
|
let isRounded = (x) => {
|
||||||
|
expectedPrecision = 2;
|
||||||
|
let rounded = (Math.floor(x / expectedPrecision) * expectedPrecision);
|
||||||
|
// First we do the perfectly normal check that should work just fine
|
||||||
|
if (rounded === x || x === 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// When we're diving by non-whole numbers, we may not get perfect
|
||||||
|
// multiplication/division because of floating points.
|
||||||
|
// When dealing with ms since epoch, a double's precision is on the order
|
||||||
|
// of 1/5 of a microsecond, so we use a value a little higher than that as
|
||||||
|
// our epsilon.
|
||||||
|
// To be clear, this error is introduced in our re-calculation of 'rounded'
|
||||||
|
// above in JavaScript.
|
||||||
|
if (Math.abs(rounded - x + expectedPrecision) < .0005) {
|
||||||
|
return true;
|
||||||
|
} else if (Math.abs(rounded - x) < .0005) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then we handle the case where you're sub-millisecond and the timer is not
|
||||||
|
// We check that the timer is not sub-millisecond by assuming it is not if it
|
||||||
|
// returns an even number of milliseconds
|
||||||
|
if (expectedPrecision < 1 && Math.round(x) == x) {
|
||||||
|
if (Math.round(rounded) == x) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok(false, "Looming Test Failure, Additional Debugging Info: Expected Precision: " + expectedPrecision + " Measured Value: " + x +
|
||||||
|
" Rounded Vaue: " + rounded + " Fuzzy1: " + Math.abs(rounded - x + expectedPrecision) +
|
||||||
|
" Fuzzy 2: " + Math.abs(rounded - x));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ================================================================================================
|
||||||
|
// ================================================================================================
|
||||||
|
|
||||||
|
function* checkWorker(worker) {
|
||||||
|
// The child worker will send the results back.
|
||||||
|
let checkWorkerTimeStamps = () => new Promise(function(resolve) {
|
||||||
|
let onMessage = function(event) {
|
||||||
|
worker.removeEventListener("message", onMessage);
|
||||||
|
|
||||||
|
let timeStamps = event.data;
|
||||||
|
for (let i = 0; i < timeStampCodes.length; i++) {
|
||||||
|
let timeStamp = timeStamps[i];
|
||||||
|
ok(isRounded(timeStamp),
|
||||||
|
"'" + timeStampCodes[i] +
|
||||||
|
"' should be rounded to nearest 2 ms in workers; saw " +
|
||||||
|
timeStamp);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
worker.addEventListener("message", onMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send the codes to its child worker.
|
||||||
|
worker.postMessage(timeStampCodes);
|
||||||
|
|
||||||
|
// First, check the child's results.
|
||||||
|
yield checkWorkerTimeStamps();
|
||||||
|
// Then, check the grandchild's results.
|
||||||
|
yield checkWorkerTimeStamps();
|
||||||
|
|
||||||
|
worker.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
add_task(function*() {
|
||||||
|
let worker = new Worker("worker_child.js");
|
||||||
|
// Allow ~550 ms to elapse, so we can get non-zero
|
||||||
|
// time values for all elements.
|
||||||
|
yield new Promise(resolve => window.setTimeout(resolve, 550));
|
||||||
|
yield checkWorker(worker);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================================================
|
||||||
|
// ================================================================================================
|
||||||
|
|
||||||
|
|
||||||
|
add_task(function*() {
|
||||||
|
// Loop through each timeStampCode, evaluate it,
|
||||||
|
// and check if it is rounded
|
||||||
|
for (let timeStampCode of timeStampCodesDOM) {
|
||||||
|
let timeStamp = eval(timeStampCode);
|
||||||
|
ok(isRounded(timeStamp),
|
||||||
|
"'" + timeStampCode + "' should be rounded to nearest 2ms" +
|
||||||
|
" saw " + timeStamp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
dom/tests/mochitest/general/worker_child.js
Executable file
28
dom/tests/mochitest/general/worker_child.js
Executable file
|
|
@ -0,0 +1,28 @@
|
||||||
|
let timeStampCodes;
|
||||||
|
let worker = new Worker("worker_grandchild.js");
|
||||||
|
|
||||||
|
function listenToParent(event) {
|
||||||
|
self.removeEventListener("message", listenToParent);
|
||||||
|
timeStampCodes = event.data;
|
||||||
|
|
||||||
|
let timeStamps = [];
|
||||||
|
for (let timeStampCode of timeStampCodes) {
|
||||||
|
timeStamps.push(eval(timeStampCode));
|
||||||
|
}
|
||||||
|
// Send the timeStamps to the parent.
|
||||||
|
postMessage(timeStamps);
|
||||||
|
|
||||||
|
// Tell the grandchild to start.
|
||||||
|
worker.postMessage(timeStampCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The worker grandchild will send results back.
|
||||||
|
function listenToChild(event) {
|
||||||
|
worker.removeEventListener("message", listenToChild);
|
||||||
|
// Pass the results to the parent.
|
||||||
|
postMessage(event.data);
|
||||||
|
worker.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.addEventListener("message", listenToChild);
|
||||||
|
self.addEventListener("message", listenToParent);
|
||||||
10
dom/tests/mochitest/general/worker_grandchild.js
Executable file
10
dom/tests/mochitest/general/worker_grandchild.js
Executable file
|
|
@ -0,0 +1,10 @@
|
||||||
|
self.addEventListener("message", function(event) {
|
||||||
|
let timeStampCodes = event.data;
|
||||||
|
|
||||||
|
let timeStamps = [];
|
||||||
|
for (let timeStampCode of timeStampCodes) {
|
||||||
|
timeStamps.push(eval(timeStampCode));
|
||||||
|
}
|
||||||
|
// Send the timeStamps to the parent.
|
||||||
|
postMessage(timeStamps);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue