зеркало из https://github.com/mozilla/treeherder.git
Bug 1465987 - Convert the BugFiler to ReactJS (#3878)
This commit is contained in:
Родитель
f4fa05ea3f
Коммит
ed2e800d90
|
@ -1,237 +0,0 @@
|
|||
/* jasmine specs for controllers go here */
|
||||
|
||||
describe('BugFilerCtrl', function() {
|
||||
var $httpBackend, controller, bugFilerScope, $uibModalInstance;
|
||||
|
||||
beforeEach(angular.mock.module('treeherder.app'));
|
||||
|
||||
beforeEach(inject(function ($injector, $rootScope, $controller) {
|
||||
$httpBackend = $injector.get('$httpBackend');
|
||||
jasmine.getJSONFixtures().fixturesPath='base/tests/ui/mock';
|
||||
|
||||
$httpBackend.whenGET('https://hg.mozilla.org/mozilla-central/json-mozbuildinfo?p=browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js').respond({
|
||||
"aggregate": {
|
||||
"bug_component_counts": [
|
||||
[
|
||||
[
|
||||
"Firefox",
|
||||
"Search"
|
||||
],
|
||||
1
|
||||
]
|
||||
],
|
||||
"recommended_bug_component": [
|
||||
"Firefox",
|
||||
"Search"
|
||||
]
|
||||
},
|
||||
"files": {
|
||||
"browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js": {
|
||||
"bug_component": [
|
||||
"Firefox",
|
||||
"Search"
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$httpBackend.whenGET('https://bugzilla.mozilla.org/rest/prod_comp_search/firefox%20::%20search?limit=5').respond({
|
||||
"products":[
|
||||
{
|
||||
"product":"Firefox"
|
||||
},
|
||||
{
|
||||
"component":"Search",
|
||||
"product":"Firefox"
|
||||
},
|
||||
{
|
||||
"product":"Marketplace"
|
||||
},
|
||||
{
|
||||
"component":"Search",
|
||||
"product":"Marketplace"},
|
||||
{
|
||||
"product":"Firefox for Android"
|
||||
},
|
||||
{
|
||||
"component":"Search Activity",
|
||||
"product":"Firefox for Android"
|
||||
},
|
||||
{
|
||||
"product":"Firefox OS"
|
||||
},
|
||||
{
|
||||
"component":"Gaia::Search",
|
||||
"product":"Firefox OS"
|
||||
},
|
||||
{
|
||||
"product":"Cloud Services"
|
||||
},
|
||||
{
|
||||
"component":"Operations: Storage",
|
||||
"product":"Cloud Services"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
var modalInstance = {}
|
||||
var summary = "PROCESS-CRASH | browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js | application crashed [@ js::GCMarker::eagerlyMarkChildren]";
|
||||
var search_terms = ["browser_searchbar_smallpanel_keyboard_navigation.js", "[@ js::GCMarker::eagerlyMarkChildren]"];
|
||||
var fullLog = "https://queue.taskcluster.net/v1/task/AGs4CgN_RnCTb943uQn8NQ/runs/0/artifacts/public/logs/live_backing.log";
|
||||
var parsedLog = "http://localhost:5000/logviewer.html#?job_id=89017089&repo=mozilla-inbound";
|
||||
var reftest = "";
|
||||
var selectedJob = {
|
||||
build_architecture: "-",
|
||||
build_os: "-",
|
||||
build_platform: "linux64",
|
||||
build_platform_id: 106,
|
||||
build_system_type: "taskcluster",
|
||||
end_timestamp: 1491433995,
|
||||
failure_classification_id: 1,
|
||||
id: 89017089,
|
||||
job_group_description: "",
|
||||
job_group_id: 257,
|
||||
job_group_name: "Mochitests executed by TaskCluster",
|
||||
job_group_symbol: "tc-M",
|
||||
job_guid: "006b380a-037f-4670-936f-de37b909fc35/0",
|
||||
job_type_description: "",
|
||||
job_type_id: 33323,
|
||||
job_type_name: "test-linux64/debug-mochitest-browser-chrome-10",
|
||||
job_type_symbol: "bc10",
|
||||
last_modified: "2017-04-05T23:13:19.178440",
|
||||
machine_name: "i-0c32950c0d0ce1419",
|
||||
machine_platform_architecture: "-",
|
||||
machine_platform_os: "-",
|
||||
option_collection_hash: "32faaecac742100f7753f0c1d0aa0add01b4046b",
|
||||
platform: "linux64",
|
||||
platform_option: "debug",
|
||||
push_id: 189151,
|
||||
reason: "scheduled",
|
||||
ref_data_name: "81213da4a447ba8918bdbe81152e5c1aa3d24365",
|
||||
result: "testfailed",
|
||||
result_set_id: 189151,
|
||||
revision: "718fb66559f71d1838b3bc6b187e050d44e3f566",
|
||||
signature: "81213da4a447ba8918bdbe81152e5c1aa3d24365",
|
||||
start_timestamp: 1491432262,
|
||||
state: "completed",
|
||||
submit_timestamp: 1491430185,
|
||||
tier: 1,
|
||||
visible: true,
|
||||
who: "ryanvm@gmail.com"
|
||||
}
|
||||
var allFailures = [
|
||||
["ShutdownLeaks", "process() called before end of test suite"],
|
||||
["browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js", "application terminated with exit code 11"],
|
||||
["browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js", "application crashed [@ js::GCMarker::eagerlyMarkChildren]"],
|
||||
["leakcheck", "default process: missing output line for total leaks!"],
|
||||
["# TBPL FAILURE #"]
|
||||
];
|
||||
var crashSignatures = ["@ js::GCMarker::eagerlyMarkChildren"];
|
||||
var successCallback = "";
|
||||
|
||||
|
||||
bugFilerScope = $rootScope.$new();
|
||||
bugFilerScope.suggestedProducts = [];
|
||||
$controller('BugFilerCtrl', {
|
||||
'$scope': bugFilerScope,
|
||||
'$uibModalInstance': modalInstance,
|
||||
'summary': summary,
|
||||
'search_terms': search_terms,
|
||||
'fullLog': fullLog,
|
||||
'parsedLog': parsedLog,
|
||||
'reftest': reftest,
|
||||
'selectedJob': selectedJob,
|
||||
'allFailures': allFailures,
|
||||
'crashSignatures': crashSignatures,
|
||||
'successCallback': successCallback,
|
||||
});
|
||||
}));
|
||||
|
||||
/*
|
||||
Tests BugFilerCtrl
|
||||
*/
|
||||
it('should parse summaries', function() {
|
||||
// Test parsing mochitest-bc failures
|
||||
var summary = "browser/components/sessionstore/test/browser_625016.js | observe1: 1 window in data written to disk - Got 0, expected 1";
|
||||
summary = bugFilerScope.parseSummary(summary);
|
||||
expect(summary[0][0]).toBe("browser/components/sessionstore/test/browser_625016.js");
|
||||
expect(summary[0][1]).toBe("observe1: 1 window in data written to disk - Got 0, expected 1");
|
||||
expect(summary[1]).toBe("browser_625016.js");
|
||||
|
||||
// Test parsing accessibility failures
|
||||
summary = "chrome://mochitests/content/a11y/accessible/tests/mochitest/states/test_expandable.xul" +
|
||||
" | uncaught exception - TypeError: this.textbox.popup.oneOffButtons is undefined at " +
|
||||
"searchbar_XBL_Constructor@chrome://browser/content/search/search.xml:95:9";
|
||||
summary = bugFilerScope.parseSummary(summary);
|
||||
expect(summary[0][0]).toBe("accessible/tests/mochitest/states/test_expandable.xul");
|
||||
expect(summary[0][1]).toBe("uncaught exception - TypeError: this.textbox.popup.oneOffButtons is undefined at " +
|
||||
"searchbar_XBL_Constructor@chrome://browser/content/search/search.xml:95:9");
|
||||
expect(summary[1]).toBe("test_expandable.xul");
|
||||
|
||||
// Test parsing xpcshell failures
|
||||
summary = "xpcshell-child-process.ini:dom/indexedDB/test/unit/test_rename_objectStore_errors.js | application crashed [@ mozalloc_abort(char const*)]";
|
||||
summary = bugFilerScope.parseSummary(summary);
|
||||
expect(summary[0][0]).toBe("dom/indexedDB/test/unit/test_rename_objectStore_errors.js");
|
||||
expect(summary[0][1]).toBe("application crashed [@ mozalloc_abort(char const*)]");
|
||||
expect(summary[1]).toBe("test_rename_objectStore_errors.js");
|
||||
|
||||
summary = "xpcshell-unpack.ini:dom/indexedDB/test/unit/test_rename_objectStore_errors.js | application crashed [@ mozalloc_abort(char const*)]";
|
||||
summary = bugFilerScope.parseSummary(summary);
|
||||
expect(summary[0][0]).toBe("dom/indexedDB/test/unit/test_rename_objectStore_errors.js");
|
||||
expect(summary[0][1]).toBe("application crashed [@ mozalloc_abort(char const*)]");
|
||||
expect(summary[1]).toBe("test_rename_objectStore_errors.js");
|
||||
|
||||
summary = "xpcshell.ini:dom/indexedDB/test/unit/test_rename_objectStore_errors.js | application crashed [@ mozalloc_abort(char const*)]";
|
||||
summary = bugFilerScope.parseSummary(summary);
|
||||
expect(summary[0][0]).toBe("dom/indexedDB/test/unit/test_rename_objectStore_errors.js");
|
||||
expect(summary[0][1]).toBe("application crashed [@ mozalloc_abort(char const*)]");
|
||||
expect(summary[1]).toBe("test_rename_objectStore_errors.js");
|
||||
|
||||
// Test parsing Windows reftests on C drive
|
||||
summary = "file:///C:/slave/test/build/tests/reftest/tests/layout/reftests/w3c-css/submitted/variables/variable-supports-12.html | application timed out after 330 seconds with no output";
|
||||
summary = bugFilerScope.parseSummary(summary);
|
||||
expect(summary[0][0]).toBe("layout/reftests/w3c-css/submitted/variables/variable-supports-12.html");
|
||||
expect(summary[0][1]).toBe("application timed out after 330 seconds with no output");
|
||||
expect(summary[1]).toBe("variable-supports-12.html");
|
||||
|
||||
// Test parsing Linux reftests
|
||||
summary = "file:///home/worker/workspace/build/tests/reftest/tests/image/test/reftest/encoders-lossless/size-7x7.png | application timed out after 330 seconds with no output";
|
||||
summary = bugFilerScope.parseSummary(summary);
|
||||
expect(summary[0][0]).toBe("image/test/reftest/encoders-lossless/size-7x7.png");
|
||||
expect(summary[0][1]).toBe("application timed out after 330 seconds with no output");
|
||||
expect(summary[1]).toBe("size-7x7.png");
|
||||
|
||||
// Test parsing Windows reftests on Z drive
|
||||
summary = "file:///Z:/task_1491428153/build/tests/reftest/tests/layout/reftests/font-face/src-list-local-full.html == file:///Z:/task_1491428153/build/tests/reftest/tests/layout/reftests/font-face/src-list-local-full-ref.html | image comparison, max difference: 255, number of differing pixels: 5184";
|
||||
summary = bugFilerScope.parseSummary(summary);
|
||||
expect(summary[0][0]).toBe("layout/reftests/font-face/src-list-local-full.html == layout/reftests/font-face/src-list-local-full-ref.html");
|
||||
expect(summary[0][1]).toBe("image comparison, max difference: 255, number of differing pixels: 5184");
|
||||
expect(summary[1]).toBe("src-list-local-full.html");
|
||||
|
||||
// Test parsing android reftests
|
||||
summary = "http://10.0.2.2:8854/tests/layout/reftests/css-display/display-contents-style-inheritance-1.html == http://10.0.2.2:8854/tests/layout/reftests/css-display/display-contents-style-inheritance-1-ref.html | image comparison, max difference: 255, number of differing pixels: 699";
|
||||
summary = bugFilerScope.parseSummary(summary);
|
||||
expect(summary[0][0]).toBe("layout/reftests/css-display/display-contents-style-inheritance-1.html == layout/reftests/css-display/display-contents-style-inheritance-1-ref.html");
|
||||
expect(summary[0][1]).toBe("image comparison, max difference: 255, number of differing pixels: 699");
|
||||
expect(summary[1]).toBe("display-contents-style-inheritance-1.html");
|
||||
|
||||
// Test parsing reftest unexpected pass
|
||||
summary = "REFTEST TEST-UNEXPECTED-PASS | file:///home/worker/workspace/build/tests/reftest/tests/layout/" +
|
||||
"reftests/backgrounds/vector/empty/wide--cover--width.html == file:///home/worker/workspace/" +
|
||||
"build/tests/reftest/tests/layout/reftests/backgrounds/vector/empty/ref-wide-lime.html | image comparison";
|
||||
summary = bugFilerScope.parseSummary(summary);
|
||||
expect(summary[0][0]).toBe("TEST-UNEXPECTED-PASS");
|
||||
expect(summary[0][1]).toBe("layout/reftests/backgrounds/vector/empty/wide--cover--width.html == layout/reftests/backgrounds/vector/empty/ref-wide-lime.html");
|
||||
expect(summary[0][2]).toBe("image comparison");
|
||||
expect(summary[1]).toBe("wide--cover--width.html");
|
||||
|
||||
// Test finding the filename when the `TEST-FOO` is not omitted
|
||||
summary = "TEST-UNEXPECTED-CRASH | /service-workers/service-worker/xhr.https.html | expected OK";
|
||||
summary = bugFilerScope.parseSummary(summary);
|
||||
expect(summary[0][0]).toBe("TEST-UNEXPECTED-CRASH");
|
||||
expect(summary[0][1]).toBe("/service-workers/service-worker/xhr.https.html");
|
||||
expect(summary[0][2]).toBe("expected OK");
|
||||
expect(summary[1]).toBe("xhr.https.html");
|
||||
});
|
||||
|
||||
});
|
|
@ -0,0 +1,233 @@
|
|||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import * as fetchMock from 'fetch-mock';
|
||||
|
||||
import { hgBaseUrl, bzBaseUrl } from '../../../../ui/helpers/url';
|
||||
import { isReftest } from '../../../../ui/helpers/job';
|
||||
import BugFiler from '../../../../ui/job-view/details/BugFiler';
|
||||
|
||||
describe('BugFiler', function () {
|
||||
const fullLog = 'https://queue.taskcluster.net/v1/task/AGs4CgN_RnCTb943uQn8NQ/runs/0/artifacts/public/logs/live_backing.log';
|
||||
const parsedLog = 'http://localhost:5000/logviewer.html#?job_id=89017089&repo=mozilla-inbound';
|
||||
const reftest = '';
|
||||
const selectedJob = {
|
||||
job_group_name: 'Mochitests executed by TaskCluster',
|
||||
job_type_name: 'test-linux64/debug-mochitest-browser-chrome-10',
|
||||
};
|
||||
const suggestions = [
|
||||
{ search: 'ShutdownLeaks | process() called before end of test suite' },
|
||||
{ search: 'browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js | application terminated with exit code 11' },
|
||||
{ search: 'browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js | application crashed [@ js::GCMarker::eagerlyMarkChildren]' },
|
||||
{ search: 'leakcheck | default process: missing output line for total leaks!' },
|
||||
{ search: '# TBPL FAILURE #' },
|
||||
];
|
||||
const successCallback = () => {};
|
||||
const toggle = () => {};
|
||||
const isOpen = true;
|
||||
|
||||
beforeEach(() => {
|
||||
jasmine.getJSONFixtures().fixturesPath = 'base/tests/ui/mock';
|
||||
|
||||
fetchMock.get(
|
||||
`${hgBaseUrl}mozilla-central/json-mozbuildinfo?p=browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js`,
|
||||
{
|
||||
aggregate: {
|
||||
bug_component_counts: [[['Firefox', 'Search'], 1]],
|
||||
recommended_bug_component: ['Firefox', 'Search'],
|
||||
},
|
||||
files: {
|
||||
'browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js': {
|
||||
bug_component: ['Firefox', 'Search'],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
fetchMock.get(
|
||||
`${bzBaseUrl}rest/prod_comp_search/firefox%20::%20search?limit=5`,
|
||||
{ products: [
|
||||
{ product: 'Firefox' },
|
||||
{ component: 'Search', product: 'Firefox' },
|
||||
{ product: 'Marketplace' },
|
||||
{ component: 'Search', product: 'Marketplace' },
|
||||
{ product: 'Firefox for Android' },
|
||||
{ component: 'Search Activity', product: 'Firefox for Android' },
|
||||
{ product: 'Firefox OS' },
|
||||
{ component: 'Gaia::Search', product: 'Firefox OS' },
|
||||
{ product: 'Cloud Services' },
|
||||
{ component: 'Operations: Storage', product: 'Cloud Services' },
|
||||
] },
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
const getBugFilerForSummary = (summary) => {
|
||||
const suggestion = {
|
||||
summary,
|
||||
search_terms: ['browser_searchbar_smallpanel_keyboard_navigation.js", "[@ js::GCMarker::eagerlyMarkChildren]'],
|
||||
search: summary,
|
||||
};
|
||||
|
||||
return mount(
|
||||
<BugFiler
|
||||
isOpen={isOpen}
|
||||
toggle={toggle}
|
||||
suggestion={suggestion}
|
||||
suggestions={suggestions}
|
||||
fullLog={fullLog}
|
||||
parsedLog={parsedLog}
|
||||
reftestUrl={isReftest(selectedJob) ? reftest : ''}
|
||||
successCallback={successCallback}
|
||||
jobGroupName={selectedJob.job_group_name}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
it('parses a crash suggestion', () => {
|
||||
const summary = 'PROCESS-CRASH | browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js | application crashed [@ js::GCMarker::eagerlyMarkChildren]';
|
||||
const bugFiler = getBugFilerForSummary(summary);
|
||||
const parsedSummary = bugFiler.state().parsedSummary;
|
||||
expect(parsedSummary[0][0]).toEqual('browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js');
|
||||
});
|
||||
|
||||
it('should parse mochitest-bc summaries', () => {
|
||||
const rawSummary = 'browser/components/sessionstore/test/browser_625016.js | observe1: 1 window in data written to disk - Got 0, expected 1';
|
||||
const bugFiler = getBugFilerForSummary(rawSummary);
|
||||
const summary = bugFiler.state().parsedSummary;
|
||||
expect(summary[0][0]).toBe('browser/components/sessionstore/test/browser_625016.js');
|
||||
expect(summary[0][1]).toBe('observe1: 1 window in data written to disk - Got 0, expected 1');
|
||||
expect(summary[1]).toBe('browser_625016.js');
|
||||
});
|
||||
|
||||
it('should parse accessibility summaries', () => {
|
||||
const rawSummary = 'chrome://mochitests/content/a11y/accessible/tests/mochitest/states/test_expandable.xul' +
|
||||
' | uncaught exception - TypeError: this.textbox.popup.oneOffButtons is undefined at ' +
|
||||
'searchbar_XBL_Constructor@chrome://browser/content/search/search.xml:95:9';
|
||||
const bugFiler = getBugFilerForSummary(rawSummary);
|
||||
const summary = bugFiler.state().parsedSummary;
|
||||
expect(summary[0][0]).toBe('accessible/tests/mochitest/states/test_expandable.xul');
|
||||
expect(summary[0][1]).toBe('uncaught exception - TypeError: this.textbox.popup.oneOffButtons is undefined at ' +
|
||||
'searchbar_XBL_Constructor@chrome://browser/content/search/search.xml:95:9');
|
||||
expect(summary[1]).toBe('test_expandable.xul');
|
||||
});
|
||||
|
||||
it('should parse xpcshell summaries', () => {
|
||||
const rawSummary = 'xpcshell-child-process.ini:dom/indexedDB/test/unit/test_rename_objectStore_errors.js | application crashed [@ mozalloc_abort(char const*)]';
|
||||
const bugFiler = getBugFilerForSummary(rawSummary);
|
||||
const summary = bugFiler.state().parsedSummary;
|
||||
expect(summary[0][0]).toBe('dom/indexedDB/test/unit/test_rename_objectStore_errors.js');
|
||||
expect(summary[0][1]).toBe('application crashed [@ mozalloc_abort(char const*)]');
|
||||
expect(summary[1]).toBe('test_rename_objectStore_errors.js');
|
||||
});
|
||||
|
||||
it('should parse xpcshell unpack summaries', () => {
|
||||
const rawSummary = 'xpcshell-unpack.ini:dom/indexedDB/test/unit/test_rename_objectStore_errors.js | application crashed [@ mozalloc_abort(char const*)]';
|
||||
const bugFiler = getBugFilerForSummary(rawSummary);
|
||||
const summary = bugFiler.state().parsedSummary;
|
||||
expect(summary[0][0]).toBe('dom/indexedDB/test/unit/test_rename_objectStore_errors.js');
|
||||
expect(summary[0][1]).toBe('application crashed [@ mozalloc_abort(char const*)]');
|
||||
expect(summary[1]).toBe('test_rename_objectStore_errors.js');
|
||||
});
|
||||
|
||||
it('should parse xpcshell dom summaries', () => {
|
||||
const rawSummary = 'xpcshell.ini:dom/indexedDB/test/unit/test_rename_objectStore_errors.js | application crashed [@ mozalloc_abort(char const*)]';
|
||||
const bugFiler = getBugFilerForSummary(rawSummary);
|
||||
const summary = bugFiler.state().parsedSummary;
|
||||
expect(summary[0][0]).toBe('dom/indexedDB/test/unit/test_rename_objectStore_errors.js');
|
||||
expect(summary[0][1]).toBe('application crashed [@ mozalloc_abort(char const*)]');
|
||||
expect(summary[1]).toBe('test_rename_objectStore_errors.js');
|
||||
});
|
||||
|
||||
it('should parse Windows reftests on C drive summaries', () => {
|
||||
const rawSummary = 'file:///C:/slave/test/build/tests/reftest/tests/layout/reftests/w3c-css/submitted/variables/variable-supports-12.html | application timed out after 330 seconds with no output';
|
||||
const bugFiler = getBugFilerForSummary(rawSummary);
|
||||
const summary = bugFiler.state().parsedSummary;
|
||||
expect(summary[0][0]).toBe('layout/reftests/w3c-css/submitted/variables/variable-supports-12.html');
|
||||
expect(summary[0][1]).toBe('application timed out after 330 seconds with no output');
|
||||
expect(summary[1]).toBe('variable-supports-12.html');
|
||||
});
|
||||
|
||||
it('should parse Linux reftest summaries', () => {
|
||||
const rawSummary = 'file:///home/worker/workspace/build/tests/reftest/tests/image/test/reftest/encoders-lossless/size-7x7.png | application timed out after 330 seconds with no output';
|
||||
const bugFiler = getBugFilerForSummary(rawSummary);
|
||||
const summary = bugFiler.state().parsedSummary;
|
||||
expect(summary[0][0]).toBe('image/test/reftest/encoders-lossless/size-7x7.png');
|
||||
expect(summary[0][1]).toBe('application timed out after 330 seconds with no output');
|
||||
expect(summary[1]).toBe('size-7x7.png');
|
||||
});
|
||||
|
||||
it('should parse Windows reftests on Z drive summaries', () => {
|
||||
const rawSummary = 'file:///Z:/task_1491428153/build/tests/reftest/tests/layout/reftests/font-face/src-list-local-full.html == file:///Z:/task_1491428153/build/tests/reftest/tests/layout/reftests/font-face/src-list-local-full-ref.html | image comparison, max difference: 255, number of differing pixels: 5184';
|
||||
const bugFiler = getBugFilerForSummary(rawSummary);
|
||||
const summary = bugFiler.state().parsedSummary;
|
||||
expect(summary[0][0]).toBe('layout/reftests/font-face/src-list-local-full.html == layout/reftests/font-face/src-list-local-full-ref.html');
|
||||
expect(summary[0][1]).toBe('image comparison, max difference: 255, number of differing pixels: 5184');
|
||||
expect(summary[1]).toBe('src-list-local-full.html');
|
||||
});
|
||||
|
||||
it('should parse android reftests summaries', () => {
|
||||
const rawSummary = 'http://10.0.2.2:8854/tests/layout/reftests/css-display/display-contents-style-inheritance-1.html == http://10.0.2.2:8854/tests/layout/reftests/css-display/display-contents-style-inheritance-1-ref.html | image comparison, max difference: 255, number of differing pixels: 699';
|
||||
const bugFiler = getBugFilerForSummary(rawSummary);
|
||||
const summary = bugFiler.state().parsedSummary;
|
||||
expect(summary[0][0]).toBe('layout/reftests/css-display/display-contents-style-inheritance-1.html == layout/reftests/css-display/display-contents-style-inheritance-1-ref.html');
|
||||
expect(summary[0][1]).toBe('image comparison, max difference: 255, number of differing pixels: 699');
|
||||
expect(summary[1]).toBe('display-contents-style-inheritance-1.html');
|
||||
});
|
||||
|
||||
it('should parse reftest unexpected pass summaries', () => {
|
||||
const rawSummary = 'REFTEST TEST-UNEXPECTED-PASS | file:///home/worker/workspace/build/tests/reftest/tests/layout/' +
|
||||
'reftests/backgrounds/vector/empty/wide--cover--width.html == file:///home/worker/workspace/' +
|
||||
'build/tests/reftest/tests/layout/reftests/backgrounds/vector/empty/ref-wide-lime.html | image comparison';
|
||||
const bugFiler = getBugFilerForSummary(rawSummary);
|
||||
const summary = bugFiler.state().parsedSummary;
|
||||
expect(summary[0][0]).toBe('TEST-UNEXPECTED-PASS');
|
||||
expect(summary[0][1]).toBe('layout/reftests/backgrounds/vector/empty/wide--cover--width.html == layout/reftests/backgrounds/vector/empty/ref-wide-lime.html');
|
||||
expect(summary[0][2]).toBe('image comparison');
|
||||
expect(summary[1]).toBe('wide--cover--width.html');
|
||||
});
|
||||
|
||||
it('should parse finding the filename when the `TEST-FOO` is not omitted', () => {
|
||||
const rawSummary = 'TEST-UNEXPECTED-CRASH | /service-workers/service-worker/xhr.https.html | expected OK';
|
||||
const bugFiler = getBugFilerForSummary(rawSummary);
|
||||
const summary = bugFiler.state().parsedSummary;
|
||||
expect(summary[0][0]).toBe('TEST-UNEXPECTED-CRASH');
|
||||
expect(summary[0][1]).toBe('/service-workers/service-worker/xhr.https.html');
|
||||
expect(summary[0][2]).toBe('expected OK');
|
||||
expect(summary[1]).toBe('xhr.https.html');
|
||||
});
|
||||
|
||||
it('should strip omitted leads from thisFailure', () => {
|
||||
const suggestions = [
|
||||
{ bugs: {},
|
||||
search_terms: [],
|
||||
search: 'TEST-UNEXPECTED-FAIL | browser/extensions/pdfjs/test/browser_pdfjs_views.js | Test timed out -' },
|
||||
{ bugs: {},
|
||||
search_terms: [],
|
||||
search: 'TEST-UNEXPECTED-FAIL | browser/extensions/pdfjs/test/browser_pdfjs_views.js | Found a tab after previous test timed out: about:blank -' },
|
||||
{ bugs: {},
|
||||
search_terms: [],
|
||||
search: 'REFTEST TEST-UNEXPECTED-PASS | flee | floo' },
|
||||
];
|
||||
const bugFiler = mount(
|
||||
<BugFiler
|
||||
isOpen={isOpen}
|
||||
toggle={toggle}
|
||||
suggestion={suggestions[0]}
|
||||
suggestions={suggestions}
|
||||
fullLog={fullLog}
|
||||
parsedLog={parsedLog}
|
||||
reftestUrl={isReftest(selectedJob) ? reftest : ''}
|
||||
successCallback={successCallback}
|
||||
jobGroupName={selectedJob.job_group_name}
|
||||
/>,
|
||||
);
|
||||
|
||||
const thisFailure = bugFiler.state().thisFailure;
|
||||
expect(thisFailure).toBe('browser/extensions/pdfjs/test/browser_pdfjs_views.js | Test timed out -\n' +
|
||||
'browser/extensions/pdfjs/test/browser_pdfjs_views.js | Found a tab after previous test timed out: about:blank -\n' +
|
||||
'TEST-UNEXPECTED-PASS | flee | floo');
|
||||
});
|
||||
});
|
|
@ -1,106 +1,10 @@
|
|||
/*
|
||||
* Intermittent Bug Filer
|
||||
*/
|
||||
#modalForm {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#modalSummarylabel {
|
||||
margin-top: -5px;
|
||||
}
|
||||
|
||||
#modalSummary {
|
||||
width:80%;
|
||||
}
|
||||
|
||||
#modalSummaryLength {
|
||||
float:right;
|
||||
margin-right:25px;
|
||||
#summaryLength {
|
||||
font-size: large;
|
||||
font-weight: bold;
|
||||
color: green;
|
||||
}
|
||||
|
||||
#modalSummary:invalid + span {
|
||||
#summary:invalid + span {
|
||||
color: red;
|
||||
}
|
||||
|
||||
#modalComment {
|
||||
width:80%;
|
||||
height:150px;
|
||||
}
|
||||
|
||||
#modalForm > div:not(#modalLogLinkCheckboxes) > label, #productFinderButton {
|
||||
float:left;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#modalLogLinkCheckboxes {
|
||||
padding-left:15px;
|
||||
padding-bottom:5px;
|
||||
color: #337AB7;
|
||||
}
|
||||
|
||||
#modalLogLinkCheckboxes a {
|
||||
color:inherit;
|
||||
}
|
||||
|
||||
#modalForm > div:after {
|
||||
content: ".";
|
||||
display: block;
|
||||
height: 0;
|
||||
clear: both;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#modalBottomDiv {
|
||||
padding:5px;
|
||||
width:650px;
|
||||
}
|
||||
|
||||
#modalBottomDiv button {
|
||||
/* float:right; */
|
||||
}
|
||||
|
||||
#modalFailureList {
|
||||
width: 80%;
|
||||
padding: 5px;
|
||||
max-height: 250px;
|
||||
overflow: auto;
|
||||
clear:both;
|
||||
}
|
||||
|
||||
#modalCrashSignatureDiv {
|
||||
float: left;
|
||||
padding: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#modalCrashSignatureLabel {
|
||||
float: left;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#modalCrashSignature {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
#modalRelatedBugs {
|
||||
padding-top: 10px;
|
||||
padding-right:10.5%;
|
||||
float: right;
|
||||
}
|
||||
|
||||
#unhelpfulSummaryReason > div:first-child {
|
||||
color: red;
|
||||
}
|
||||
|
||||
#unhelpfulSummaryReason > div:not(:first-child) {
|
||||
font-family: monospace;
|
||||
padding: 3px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
#productSearchSpinner {
|
||||
padding: 6px;
|
||||
}
|
||||
|
|
|
@ -40,6 +40,5 @@ import './js/models/resultsets_store';
|
|||
import './js/models/repository';
|
||||
import './js/models/perf/series';
|
||||
import './js/controllers/main';
|
||||
import './js/controllers/bugfiler';
|
||||
import './js/controllers/tcjobactions';
|
||||
import './js/filters';
|
||||
|
|
|
@ -133,3 +133,9 @@ export const getRepoUrl = function getRepoUrl(newRepoName) {
|
|||
params.set('repo', newRepoName);
|
||||
return `${uiJobsUrlBase}?${params.toString()}`;
|
||||
};
|
||||
|
||||
export const bzBaseUrl = 'https://bugzilla.mozilla.org/';
|
||||
|
||||
export const hgBaseUrl = 'https://hg.mozilla.org/';
|
||||
|
||||
export const dxrBaseUrl = 'https://dxr.mozilla.org/';
|
||||
|
|
|
@ -0,0 +1,613 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Button, Modal, ModalHeader, ModalBody, ModalFooter, Tooltip, FormGroup, Input,
|
||||
Label,
|
||||
} from 'reactstrap';
|
||||
|
||||
import {
|
||||
bzBaseUrl,
|
||||
dxrBaseUrl,
|
||||
getApiUrl,
|
||||
hgBaseUrl,
|
||||
} from '../../helpers/url';
|
||||
import { create } from '../../helpers/http';
|
||||
|
||||
const crashRegex = /application crashed \[@ (.+)\]$/g;
|
||||
const omittedLeads = ['TEST-UNEXPECTED-FAIL', 'PROCESS-CRASH', 'TEST-UNEXPECTED-ERROR', 'REFTEST ERROR'];
|
||||
/*
|
||||
* Find the first thing in the summary line that looks like a filename.
|
||||
*/
|
||||
const findFilename = (summary) => {
|
||||
// Take left side of any reftest comparisons, as the right side is the reference file
|
||||
summary = summary.split('==')[0];
|
||||
// Take the leaf node of unix paths
|
||||
summary = summary.split('/').pop();
|
||||
// Take the leaf node of Windows paths
|
||||
summary = summary.split('\\').pop();
|
||||
// Remove leading/trailing whitespace
|
||||
summary = summary.trim();
|
||||
// If there's a space in what's remaining, take the first word
|
||||
summary = summary.split(' ')[0];
|
||||
return summary;
|
||||
};
|
||||
/*
|
||||
* Remove extraneous junk from the start of the summary line
|
||||
* and try to find the failing test name from what's left
|
||||
*/
|
||||
const parseSummary = (suggestion) => {
|
||||
let summary = suggestion.search;
|
||||
const searchTerms = suggestion.search_terms;
|
||||
// Strip out some extra stuff at the start of some failure paths
|
||||
let re = /file:\/\/\/.*?\/build\/tests\/reftest\/tests\//gi;
|
||||
summary = summary.replace(re, '');
|
||||
re = /\/home\/worker\/workspace\/build\/src\//gi;
|
||||
summary = summary.replace(re, '');
|
||||
re = /chrome:\/\/mochitests\/content\/a11y\//gi;
|
||||
summary = summary.replace(re, '');
|
||||
re = /\/home\/worker\/checkouts\/gecko\//gi;
|
||||
summary = summary.replace(re, '');
|
||||
re = /http:\/\/([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+):([0-9]+)\/tests\//gi;
|
||||
summary = summary.replace(re, '');
|
||||
re = /jetpack-package\//gi;
|
||||
summary = summary.replace(re, '');
|
||||
re = /xpcshell([-a-zA-Z0-9]+)?.ini:/gi;
|
||||
summary = summary.replace(re, '');
|
||||
summary = summary.replace('/_mozilla/', 'mozilla/tests/');
|
||||
// We don't want to include "REFTEST" when it's an unexpected pass
|
||||
summary = summary.replace('REFTEST TEST-UNEXPECTED-PASS', 'TEST-UNEXPECTED-PASS');
|
||||
const summaryParts = summary.split(' | ');
|
||||
|
||||
// If the search_terms used for finding bug suggestions
|
||||
// contains any of the omittedLeads, that lead is needed
|
||||
// for the full string match, so don't omit it in this case.
|
||||
// If it's not needed, go ahead and omit it.
|
||||
if (searchTerms.length && summaryParts.length > 1) {
|
||||
omittedLeads.forEach((lead) => {
|
||||
if (!searchTerms[0].includes(lead) && summaryParts[0].includes(lead)) {
|
||||
summaryParts.shift();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Some of the TEST-FOO bits aren't removed from the summary,
|
||||
// so we sometimes end up with them instead of the test path here.
|
||||
const summaryName = summaryParts[0].startsWith('TEST-') && summaryParts.length > 1 ? summaryParts[1] : summaryParts[0];
|
||||
const possibleFilename = findFilename(summaryName);
|
||||
|
||||
return [summaryParts, possibleFilename];
|
||||
};
|
||||
|
||||
export default class BugFiler extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const { suggestions, suggestion, fullLog, parsedLog, reftestUrl, jobGroupName } = props;
|
||||
|
||||
const allFailures = suggestions.map(sugg => sugg.search
|
||||
.split(' | ')
|
||||
.filter(part => !omittedLeads.includes(part))
|
||||
.map(item => (item === 'REFTEST TEST-UNEXPECTED-PASS' ? 'TEST-UNEXPECTED-PASS' : item)),
|
||||
);
|
||||
const thisFailure = allFailures.map(f => f.join(' | ')).join('\n');
|
||||
const crash = suggestion.search.match(crashRegex);
|
||||
const crashSignatures = crash ? [crash[0].split('application crashed ')[1]] : [];
|
||||
const parsedSummary = parseSummary(suggestion);
|
||||
|
||||
let summaryString = parsedSummary[0].join(' | ');
|
||||
if (jobGroupName.toLowerCase().includes('reftest')) {
|
||||
const re = /layout\/reftests\//gi;
|
||||
summaryString = summaryString.replace(re, '');
|
||||
}
|
||||
|
||||
const checkedLogLinks = [parsedLog, fullLog];
|
||||
if (reftestUrl) {
|
||||
checkedLogLinks.push(reftestUrl);
|
||||
}
|
||||
|
||||
this.state = {
|
||||
tooltipOpen: {},
|
||||
summary: `Intermittent ${summaryString}`,
|
||||
parsedLog: null,
|
||||
productSearch: null,
|
||||
suggestedProducts: [],
|
||||
isFilerSummaryVisible: false,
|
||||
possibleFilename: null,
|
||||
selectedProduct: null,
|
||||
isIntermittent: true,
|
||||
searching: false,
|
||||
parsedSummary,
|
||||
checkedLogLinks,
|
||||
thisFailure,
|
||||
crashSignatures,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.submitFiler = this.submitFiler.bind(this);
|
||||
this.findProduct = this.findProduct.bind(this);
|
||||
this.productSearchEnter = this.productSearchEnter.bind(this);
|
||||
this.toggleTooltip = this.toggleTooltip.bind(this);
|
||||
}
|
||||
|
||||
getUnhelpfulSummaryReason(summary) {
|
||||
const { suggestion } = this.props;
|
||||
const searchTerms = suggestion.search_terms;
|
||||
|
||||
if (searchTerms.length === 0) {
|
||||
return 'Selected failure does not contain any searchable terms.';
|
||||
}
|
||||
if (searchTerms.every(term => !summary.includes(term))) {
|
||||
return 'Summary does not include the full text of any of the selected failure\'s search terms:';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Some job types are special, lets explicitly handle them.
|
||||
getSpecialProducts(fp) {
|
||||
const { jobGroupName } = this.props;
|
||||
const { suggestedProducts } = this.state;
|
||||
const newProducts = [];
|
||||
|
||||
if (suggestedProducts.length === 0) {
|
||||
const jg = jobGroupName.toLowerCase();
|
||||
|
||||
if (jg.includes('talos')) {
|
||||
newProducts.push('Testing :: Talos');
|
||||
}
|
||||
if (jg.includes('mochitest') && (fp.includes('webextensions/') || fp.includes('components/extensions'))) {
|
||||
newProducts.push('WebExtensions :: General');
|
||||
}
|
||||
if (jg.includes('mochitest') && fp.includes('webrtc/')) {
|
||||
newProducts.push('Core :: WebRTC');
|
||||
}
|
||||
}
|
||||
return newProducts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 'enter' from the product search input should initiate the search
|
||||
*/
|
||||
productSearchEnter(ev) {
|
||||
const { keyCode, target } = ev;
|
||||
|
||||
this.setState({ productSearch: target.value }, () => {
|
||||
if (keyCode === 13) {
|
||||
this.findProduct();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Attempt to find a good product/component for this failure
|
||||
*/
|
||||
async findProduct() {
|
||||
const { jobGroupName } = this.props;
|
||||
const { productSearch, parsedSummary } = this.state;
|
||||
|
||||
let possibleFilename = null;
|
||||
let suggestedProductsSet = new Set();
|
||||
|
||||
this.setState({ searching: true });
|
||||
|
||||
if (productSearch) {
|
||||
const resp = await fetch(`${bzBaseUrl}rest/prod_comp_search/${productSearch}?limit=5`);
|
||||
const data = await resp.json();
|
||||
const products = data.products.filter(item => !!item.product && !!item.component);
|
||||
suggestedProductsSet = new Set([...suggestedProductsSet, ...products.map(prod => (
|
||||
prod.product + (prod.component ? ` :: ${prod.component}` : '')
|
||||
))]);
|
||||
} else {
|
||||
let failurePath = parsedSummary[0][0];
|
||||
|
||||
// If the "TEST-UNEXPECTED-foo" isn't one of the omitted ones, use the next piece in the summary
|
||||
if (failurePath.includes('TEST-UNEXPECTED-')) {
|
||||
failurePath = parsedSummary[0][1];
|
||||
possibleFilename = findFilename(failurePath);
|
||||
}
|
||||
|
||||
const lowerJobGroupName = jobGroupName.toLowerCase();
|
||||
// Try to fix up file paths for some job types.
|
||||
if (lowerJobGroupName.includes('spidermonkey')) {
|
||||
failurePath = 'js/src/tests/' + failurePath;
|
||||
}
|
||||
if (lowerJobGroupName.includes('videopuppeteer ')) {
|
||||
failurePath = failurePath.replace('FAIL ', '');
|
||||
failurePath = 'dom/media/test/external/external_media_tests/' + failurePath;
|
||||
}
|
||||
if (lowerJobGroupName.includes('web platform')) {
|
||||
failurePath = failurePath.startsWith('mozilla/tests') ?
|
||||
`testing/web-platform/${failurePath}` :
|
||||
`testing/web-platform/tests/${failurePath}`;
|
||||
}
|
||||
|
||||
// Search mercurial's moz.build metadata to find products/components
|
||||
fetch(`${hgBaseUrl}mozilla-central/json-mozbuildinfo?p=${failurePath}`)
|
||||
.then(resp => resp.json().then((firstRequest) => {
|
||||
|
||||
if (firstRequest.data.aggregate && firstRequest.data.aggregate.recommended_bug_component) {
|
||||
const suggested = firstRequest.data.aggregate.recommended_bug_component;
|
||||
suggestedProductsSet.add(`${suggested[0]} :: ${suggested[1]}`);
|
||||
}
|
||||
|
||||
// Make an attempt to find the file path via a dxr file search
|
||||
if (suggestedProductsSet.size === 0 && possibleFilename.length > 4) {
|
||||
const dxrlink = `${dxrBaseUrl}mozilla-central/search?q=file:${possibleFilename}&redirect=false&limit=5`;
|
||||
// Bug 1358328 - We need to override headers here until DXR returns JSON with the default Accept header
|
||||
fetch(dxrlink, { headers: { Accept: 'application/json' } })
|
||||
.then((secondRequest) => {
|
||||
const results = secondRequest.data.results;
|
||||
let resultsCount = results.length;
|
||||
// If the search returns too many results, this probably isn't a good search term, so bail
|
||||
if (resultsCount === 0) {
|
||||
suggestedProductsSet = new Set([...suggestedProductsSet, this.getSpecialProducts(failurePath)]);
|
||||
}
|
||||
results.forEach((result) => {
|
||||
fetch(`${hgBaseUrl}mozilla-central/json-mozbuildinfo?p=${result.path}`)
|
||||
.then((thirdRequest) => {
|
||||
if (thirdRequest.data.aggregate && thirdRequest.data.aggregate.recommended_bug_component) {
|
||||
const suggested = thirdRequest.data.aggregate.recommended_bug_component;
|
||||
suggestedProductsSet.add(`${suggested[0]} :: ${suggested[1]}`);
|
||||
}
|
||||
// Only get rid of the throbber when all of these searches have completed
|
||||
resultsCount -= 1;
|
||||
if (resultsCount === 0) {
|
||||
suggestedProductsSet = new Set([...suggestedProductsSet, this.getSpecialProducts(failurePath)]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
suggestedProductsSet = new Set([...suggestedProductsSet, this.getSpecialProducts(failurePath)]);
|
||||
}
|
||||
|
||||
}));
|
||||
}
|
||||
const newSuggestedProducts = [...suggestedProductsSet];
|
||||
|
||||
this.setState({
|
||||
suggestedProducts: newSuggestedProducts,
|
||||
selectedProduct: newSuggestedProducts[0],
|
||||
searching: false,
|
||||
});
|
||||
}
|
||||
|
||||
toggleCheckedLogLink(link) {
|
||||
const { checkedLogLinks } = this.state;
|
||||
const newCheckedLogLinks = checkedLogLinks.includes(link) ?
|
||||
checkedLogLinks.filter(item => item !== link) :
|
||||
[...checkedLogLinks, link];
|
||||
|
||||
this.setState({ checkedLogLinks: newCheckedLogLinks });
|
||||
}
|
||||
|
||||
/*
|
||||
* Actually send the gathered information to bugzilla.
|
||||
*/
|
||||
async submitFiler() {
|
||||
const {
|
||||
summary, selectedProduct, comment, isIntermittent, checkedLogLinks,
|
||||
blocks, dependsOn, seeAlso, crashSignatures,
|
||||
} = this.state;
|
||||
const { toggle, successCallback, notify } = this.props;
|
||||
const [product, component] = selectedProduct.split(' :: ');
|
||||
|
||||
if (!selectedProduct) {
|
||||
notify.send('Please select (or search and select) a product/component pair to continue', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
if (summary.length > 255) {
|
||||
notify.send('Please ensure the summary is no more than 255 characters', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptionStrings = [...checkedLogLinks, comment].join('\n\n');
|
||||
const keywords = isIntermittent ? ['intermittent-failure'] : [];
|
||||
|
||||
let severity = 'normal';
|
||||
const priority = 'P5';
|
||||
const crashSignature = crashSignatures.join('\n');
|
||||
if (crashSignature.length > 0) {
|
||||
keywords.push('crash');
|
||||
severity = 'critical';
|
||||
}
|
||||
|
||||
// Fetch product information from bugzilla to get version numbers, then
|
||||
// submit the new bug. Only request the versions because some products
|
||||
// take quite a long time to fetch the full object
|
||||
try {
|
||||
const productResp = await fetch(`${bzBaseUrl}rest/product/${product}?include_fields=versions`);
|
||||
const productData = await productResp.json();
|
||||
if (productResp.ok) {
|
||||
const productObject = productData.products[0];
|
||||
// Find the newest version for the product that is_active
|
||||
const version = productObject.versions.filter(prodVer => prodVer.is_active).slice(-1)[0];
|
||||
const payload = {
|
||||
product,
|
||||
component,
|
||||
summary,
|
||||
keywords,
|
||||
version: version.name,
|
||||
blocks,
|
||||
depends_on: dependsOn,
|
||||
see_also: seeAlso,
|
||||
crash_signature: crashSignature,
|
||||
severity,
|
||||
priority,
|
||||
comment: descriptionStrings,
|
||||
comment_tags: 'treeherder',
|
||||
};
|
||||
|
||||
const bugResp = await create(getApiUrl('/bugzilla/create_bug/'), payload);
|
||||
// const bugResp = await create('http://httpstat.us/404', payload);
|
||||
const data = await bugResp.json();
|
||||
if (bugResp.ok) {
|
||||
successCallback(data);
|
||||
toggle();
|
||||
} else {
|
||||
this.submitFailure('Treeherder Bug Filer API', bugResp.status, bugResp.statusText, data);
|
||||
}
|
||||
} else {
|
||||
this.submitFailure('Bugzilla', productResp.status, productResp.statusText, productData);
|
||||
}
|
||||
} catch (e) {
|
||||
notify.send(`Error filing bug: ${e.toString()}`, 'danger', { sticky: true });
|
||||
}
|
||||
}
|
||||
|
||||
submitFailure(source, status, statusText, data) {
|
||||
const { notify } = this.props;
|
||||
|
||||
let failureString = `${source} returned status ${status}(${statusText})`;
|
||||
if (data && data.failure) {
|
||||
failureString += '\n\n' + data.failure;
|
||||
}
|
||||
if (status === 403) {
|
||||
failureString += '\n\nAuthentication failed. Has your Treeherder session expired?';
|
||||
}
|
||||
notify.send(failureString, 'danger', { sticky: true });
|
||||
}
|
||||
|
||||
toggleTooltip(key) {
|
||||
const { tooltipOpen } = this.state;
|
||||
this.setState({ tooltipOpen: { ...tooltipOpen, [key]: !tooltipOpen[key] } });
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
isOpen, toggle, suggestion, parsedLog, fullLog, reftestUrl,
|
||||
} = this.props;
|
||||
const {
|
||||
productSearch, suggestedProducts, thisFailure, isFilerSummaryVisible,
|
||||
isIntermittent, summary, searching, checkedLogLinks, tooltipOpen,
|
||||
selectedProduct,
|
||||
} = this.state;
|
||||
const searchTerms = suggestion.search_terms;
|
||||
const crash = summary.match(crashRegex);
|
||||
const crashSignatures = crash ? [crash[0].split('application crashed ')[1]] : [];
|
||||
const unhelpfulSummaryReason = this.getUnhelpfulSummaryReason(summary);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Modal isOpen={isOpen} toggle={toggle} size="lg">
|
||||
<ModalHeader toggle={toggle}>Intermittent Bug Filer</ModalHeader>
|
||||
<ModalBody>
|
||||
<form>
|
||||
<div className="d-inline-flex">
|
||||
<Input
|
||||
name="modalProductFinderSearch"
|
||||
id="modalProductFinderSearch"
|
||||
onKeyDown={this.productSearchEnter}
|
||||
onChange={evt => this.setState({ productSearch: evt.target.value })}
|
||||
type="text"
|
||||
placeholder="Firefox"
|
||||
className="flex-fill flex-grow-1"
|
||||
/>
|
||||
<Tooltip
|
||||
target="modalProductFinderSearch"
|
||||
isOpen={tooltipOpen.modalProductFinderSearch}
|
||||
toggle={() => this.toggleTooltip('modalProductFinderSearch')}
|
||||
>Manually search for a product</Tooltip>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="ml-1 btn-sm"
|
||||
type="button"
|
||||
onClick={this.findProduct}
|
||||
>Find Product</Button>
|
||||
</div>
|
||||
<div>
|
||||
{!!productSearch && searching && <div>
|
||||
<span className="fa fa-spinner fa-pulse th-spinner-lg" />Searching {productSearch}
|
||||
</div>}
|
||||
<FormGroup tag="fieldset" className="mt-1">
|
||||
{suggestedProducts.map(product => (
|
||||
<div className="ml-4" key={`modalProductSuggestion${product}`}>
|
||||
<Label check>
|
||||
<Input
|
||||
type="radio"
|
||||
value={product}
|
||||
checked={product === selectedProduct}
|
||||
onChange={evt => this.setState({ selectedProduct: evt.target.value })}
|
||||
name="productGroup"
|
||||
/>{product}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</FormGroup>
|
||||
</div>
|
||||
<label>Summary:</label>
|
||||
<div className="d-flex">
|
||||
{!!unhelpfulSummaryReason && <div>
|
||||
<div className="text-danger">
|
||||
<span
|
||||
className="fa fa-warning"
|
||||
id="unhelpful-summary-reason"
|
||||
/>Warning: {unhelpfulSummaryReason}
|
||||
<Tooltip
|
||||
target="unhelpful-summary-reason"
|
||||
isOpen={tooltipOpen.unhelpfulSummaryReason}
|
||||
toggle={() => this.toggleTooltip('unhelpfulSummaryReason')}
|
||||
>This can cause poor bug suggestions to be generated</Tooltip>
|
||||
</div>
|
||||
{searchTerms.map(term => <div className="text-monospace pl-3" key={term}>{term}</div>)}
|
||||
</div>}
|
||||
<Input
|
||||
id="summary"
|
||||
className="flex-grow-1"
|
||||
type="text"
|
||||
placeholder="Intermittent..."
|
||||
pattern=".{0,255}"
|
||||
onChange={evt => this.setState({ summary: evt.target.value })}
|
||||
value={summary}
|
||||
/>
|
||||
<Tooltip
|
||||
target="toggle-failure-lines"
|
||||
isOpen={tooltipOpen.toggleFailureLines}
|
||||
toggle={() => this.toggleTooltip('toggleFailureLines')}
|
||||
>
|
||||
{isFilerSummaryVisible ? 'Hide all failure lines for this job' : 'Show all failure lines for this job'}
|
||||
</Tooltip>
|
||||
<i
|
||||
onClick={() => this.setState({ isFilerSummaryVisible: !isFilerSummaryVisible })}
|
||||
className={`fa fa-lg pointable align-bottom pt-2 ml-1 ${isFilerSummaryVisible ? 'fa-chevron-circle-up' : 'fa-chevron-circle-down'}`}
|
||||
id="toggle-failure-lines"
|
||||
/>
|
||||
<span
|
||||
id="summaryLength"
|
||||
className={`ml-1 font-weight-bold lg ${summary.length > 255 ? 'text-danger' : 'text-success'}`}
|
||||
>{summary.length}</span>
|
||||
</div>
|
||||
{isFilerSummaryVisible && <span>
|
||||
<Input
|
||||
className="w-100"
|
||||
type="textarea"
|
||||
value={thisFailure}
|
||||
readOnly
|
||||
onChange={evt => this.setState({ thisFailure: evt.target.value })}
|
||||
/>
|
||||
</span>}
|
||||
<div className="ml-5 mt-2">
|
||||
<div>
|
||||
<label>
|
||||
<Input
|
||||
type="checkbox"
|
||||
checked={checkedLogLinks.includes(parsedLog)}
|
||||
onChange={() => this.toggleCheckedLogLink(parsedLog)}
|
||||
/>
|
||||
<a target="_blank" rel="noopener noreferrer" href={parsedLog}>Include Parsed Log Link</a>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<Input
|
||||
type="checkbox"
|
||||
checked={checkedLogLinks.includes(fullLog)}
|
||||
onChange={() => this.toggleCheckedLogLink(fullLog)}
|
||||
/>
|
||||
<a target="_blank" rel="noopener noreferrer" href={fullLog}>Include Full Log Link</a>
|
||||
</label>
|
||||
</div>
|
||||
{!!reftestUrl && <div><label>
|
||||
<Input
|
||||
type="checkbox"
|
||||
checked={checkedLogLinks.includes(reftestUrl)}
|
||||
onChange={() => this.toggleCheckedLogLink(reftestUrl)}
|
||||
/>
|
||||
<a target="_blank" rel="noopener noreferrer" href={reftestUrl}>Include Reftest Viewer Link</a>
|
||||
</label></div>}
|
||||
</div>
|
||||
<div>
|
||||
<label>Comment:</label>
|
||||
<Input
|
||||
onChange={evt => this.setState({ comment: evt.target.value })}
|
||||
type="textarea"
|
||||
className="h-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="d-inline-flex mt-2 ml-5">
|
||||
<div className="mt-2">
|
||||
<label>
|
||||
<Input
|
||||
onChange={() => this.setState({ isIntermittent: !isIntermittent })}
|
||||
type="checkbox"
|
||||
checked={isIntermittent}
|
||||
/>This is an intermittent failure
|
||||
</label>
|
||||
</div>
|
||||
<div className="d-inline-flex ml-2">
|
||||
<Input
|
||||
id="blocksInput"
|
||||
type="text"
|
||||
onChange={evt => this.setState({ blocks: evt.target.value })}
|
||||
placeholder="Blocks"
|
||||
/>
|
||||
<Tooltip
|
||||
target="blocksInput"
|
||||
placement="bottom"
|
||||
isOpen={tooltipOpen.blocksInput}
|
||||
toggle={() => this.toggleTooltip('blocksInput')}
|
||||
>Comma-separated list of bugs</Tooltip>
|
||||
<Input
|
||||
id="dependsOn"
|
||||
type="text"
|
||||
className="ml-1"
|
||||
onChange={evt => this.setState({ dependsOn: evt.target.value })}
|
||||
placeholder="Depends on"
|
||||
/>
|
||||
<Tooltip
|
||||
target="dependsOn"
|
||||
placement="bottom"
|
||||
isOpen={tooltipOpen.dependsOn}
|
||||
toggle={() => this.toggleTooltip('dependsOn')}
|
||||
>Comma-separated list of bugs</Tooltip>
|
||||
<Input
|
||||
id="seeAlso"
|
||||
className="ml-1"
|
||||
type="text"
|
||||
onChange={evt => this.setState({ seeAlso: evt.target.value })}
|
||||
placeholder="See also"
|
||||
/>
|
||||
<Tooltip
|
||||
target="seeAlso"
|
||||
placement="bottom"
|
||||
isOpen={tooltipOpen.seeAlso}
|
||||
toggle={() => this.toggleTooltip('seeAlso')}
|
||||
>Comma-separated list of bugs</Tooltip>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{!!crashSignatures.length && <div>
|
||||
<label>Signature:</label>
|
||||
<Input
|
||||
type="textarea"
|
||||
onChange={evt => this.setState({ crashSignatures: evt.target.value })}
|
||||
maxLength="2048"
|
||||
readOnly
|
||||
value={crashSignatures.join('\n')}
|
||||
/>
|
||||
</div>}
|
||||
</form>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="secondary" onClick={this.submitFiler}>Submit Bug</Button>{' '}
|
||||
<Button color="secondary" onClick={toggle}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BugFiler.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
suggestion: PropTypes.object.isRequired,
|
||||
suggestions: PropTypes.array.isRequired,
|
||||
fullLog: PropTypes.string.isRequired,
|
||||
parsedLog: PropTypes.string.isRequired,
|
||||
reftestUrl: PropTypes.string.isRequired,
|
||||
successCallback: PropTypes.func.isRequired,
|
||||
jobGroupName: PropTypes.string.isRequired,
|
||||
notify: PropTypes.object.isRequired,
|
||||
};
|
|
@ -144,7 +144,7 @@ export default class ErrorLine extends React.Component {
|
|||
*/
|
||||
onOptionChange(option) {
|
||||
this.initOption(option);
|
||||
this.setState({ selectedOption: option });
|
||||
this.setState({ selectedOption: { ...option } });
|
||||
}
|
||||
|
||||
onManualBugNumberChange(option, bugNumber) {
|
||||
|
|
|
@ -4,10 +4,12 @@ import { FormGroup, Label, Input } from 'reactstrap';
|
|||
import Select from 'react-select';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
|
||||
import intermittentTemplate from '../../../../partials/main/intermittent.html';
|
||||
import { getSearchWords } from '../../../../helpers/display';
|
||||
import { isReftest } from '../../../../helpers/job';
|
||||
import { getBugUrl, getLogViewerUrl, getReftestUrl } from '../../../../helpers/url';
|
||||
import BugFiler from '../../BugFiler';
|
||||
import { thEvents } from '../../../../js/constants';
|
||||
import { getAllUrlParams } from '../../../../helpers/location';
|
||||
|
||||
/**
|
||||
* Editable option
|
||||
|
@ -17,55 +19,41 @@ export default class LineOption extends React.Component {
|
|||
super(props);
|
||||
const { $injector } = props;
|
||||
|
||||
this.$uibModal = $injector.get('$uibModal');
|
||||
this.$rootScope = $injector.get('$rootScope');
|
||||
this.thNotify = $injector.get('thNotify');
|
||||
|
||||
this.state = {
|
||||
isBugFilerOpen: false,
|
||||
repoName: getAllUrlParams().repo,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fileBug = this.fileBug.bind(this);
|
||||
this.toggleBugFiler = this.toggleBugFiler.bind(this);
|
||||
this.bugFilerCallback = this.bugFilerCallback.bind(this);
|
||||
}
|
||||
|
||||
fileBug() {
|
||||
const { job, errorLine, selectedOption, optionModel, onManualBugNumberChange } = this.props;
|
||||
const repoName = this.$rootScope.repoName;
|
||||
let logUrl = job.logs.filter(x => x.name.endsWith('_json'));
|
||||
logUrl = logUrl[0] ? logUrl[0].url : job.logs[0];
|
||||
const reftestUrl = getReftestUrl(logUrl);
|
||||
const crashSignatures = [];
|
||||
const crashRegex = /application crashed \[@ (.+)\]$/g;
|
||||
const crash = errorLine.data.bug_suggestions.search.match(crashRegex);
|
||||
|
||||
if (crash) {
|
||||
const signature = crash[0].split('application crashed ')[1];
|
||||
if (!crashSignatures.includes(signature)) {
|
||||
crashSignatures.push(signature);
|
||||
}
|
||||
}
|
||||
|
||||
const modalInstance = this.$uibModal.open({
|
||||
template: intermittentTemplate,
|
||||
controller: 'BugFilerCtrl',
|
||||
size: 'lg',
|
||||
openedClass: 'filer-open',
|
||||
resolve: {
|
||||
summary: () => errorLine.data.bug_suggestions.search,
|
||||
search_terms: () => errorLine.data.bug_suggestions.search_terms,
|
||||
fullLog: () => logUrl,
|
||||
parsedLog: () => `${location.origin}/${getLogViewerUrl(job.id, repoName)}`,
|
||||
reftest: () => (isReftest(job) ? `${reftestUrl}&only_show_unexpected=1` : ''),
|
||||
selectedJob: () => job,
|
||||
allFailures: () => [errorLine.data.bug_suggestions.search.split(' | ')],
|
||||
crashSignatures: () => crashSignatures,
|
||||
successCallback: () => (data) => {
|
||||
const bugId = data.success;
|
||||
window.open(getBugUrl(bugId));
|
||||
onManualBugNumberChange(optionModel, `${bugId}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
const { selectedOption, optionModel } = this.props;
|
||||
|
||||
selectedOption.id = optionModel.id;
|
||||
modalInstance.opened.then(() => modalInstance.initiate());
|
||||
this.setState({ isBugFilerOpen: true });
|
||||
}
|
||||
|
||||
toggleBugFiler() {
|
||||
this.setState({ isBugFilerOpen: !this.state.isBugFilerOpen });
|
||||
}
|
||||
|
||||
bugFilerCallback(data) {
|
||||
const { addBug, onManualBugNumberChange, optionModel } = this.props;
|
||||
const bugId = data.success;
|
||||
|
||||
addBug({ id: bugId });
|
||||
this.$rootScope.$evalAsync(this.$rootScope.$emit(thEvents.saveClassification));
|
||||
// Open the newly filed bug in a new tab or window for further editing
|
||||
window.open(getBugUrl(bugId));
|
||||
onManualBugNumberChange(optionModel, `${bugId}`);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -83,7 +71,10 @@ export default class LineOption extends React.Component {
|
|||
pinnedJobs,
|
||||
addBug,
|
||||
} = this.props;
|
||||
const { isBugFilerOpen, repoName } = this.state;
|
||||
const option = optionModel;
|
||||
let logUrl = job.logs.filter(x => x.name.endsWith('_json'));
|
||||
logUrl = logUrl[0] ? logUrl[0].url : job.logs[0].url;
|
||||
|
||||
return (
|
||||
<div className="classification-option">
|
||||
|
@ -182,6 +173,18 @@ export default class LineOption extends React.Component {
|
|||
{match.matcher.name} ({match.score})
|
||||
</span>))}
|
||||
</div>}
|
||||
{isBugFilerOpen && <BugFiler
|
||||
isOpen={isBugFilerOpen}
|
||||
toggle={this.toggleBugFiler}
|
||||
suggestion={errorLine.data.bug_suggestions}
|
||||
suggestions={[errorLine.data.bug_suggestions]}
|
||||
fullLog={logUrl}
|
||||
parsedLog={`${location.origin}/${getLogViewerUrl(job.id, repoName)}`}
|
||||
reftestUrl={isReftest(job) ? `${getReftestUrl(logUrl)}&only_show_unexpected=1` : ''}
|
||||
successCallback={this.bugFilerCallback}
|
||||
jobGroupName={job.job_group_name}
|
||||
notify={this.thNotify}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import intermittentTemplate from '../../../../partials/main/intermittent.html';
|
||||
import { thEvents } from '../../../../js/constants';
|
||||
import { isReftest } from '../../../../helpers/job';
|
||||
import { getBugUrl } from '../../../../helpers/url';
|
||||
|
@ -9,64 +8,55 @@ import { getBugUrl } from '../../../../helpers/url';
|
|||
import ErrorsList from './ErrorsList';
|
||||
import ListItem from './ListItem';
|
||||
import SuggestionsListItem from './SuggestionsListItem';
|
||||
|
||||
import BugFiler from '../../BugFiler';
|
||||
|
||||
export default class FailureSummaryTab extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const { $injector } = this.props;
|
||||
this.$timeout = $injector.get('$timeout');
|
||||
this.$uibModal = $injector.get('$uibModal');
|
||||
this.$rootScope = $injector.get('$rootScope');
|
||||
this.thNotify = $injector.get('thNotify');
|
||||
|
||||
this.state = {
|
||||
isBugFilerOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.toggleBugFiler = this.toggleBugFiler.bind(this);
|
||||
this.bugFilerCallback = this.bugFilerCallback.bind(this);
|
||||
}
|
||||
|
||||
fileBug(suggestion) {
|
||||
const { suggestions, jobLogUrls, logViewerFullUrl, selectedJob, reftestUrl, addBug, pinJob } = this.props;
|
||||
const summary = suggestion.search;
|
||||
const crashRegex = /application crashed \[@ (.+)\]$/g;
|
||||
const crash = summary.match(crashRegex);
|
||||
const crashSignatures = crash ? [crash[0].split('application crashed ')[1]] : [];
|
||||
const allFailures = suggestions.map(sugg => (sugg.search.split(' | ')));
|
||||
const { selectedJob, pinJob } = this.props;
|
||||
|
||||
const modalInstance = this.$uibModal.open({
|
||||
template: intermittentTemplate,
|
||||
controller: 'BugFilerCtrl',
|
||||
size: 'lg',
|
||||
openedClass: 'filer-open',
|
||||
resolve: {
|
||||
summary: () => (summary),
|
||||
search_terms: () => (suggestion.search_terms),
|
||||
fullLog: () => (jobLogUrls[0].url),
|
||||
parsedLog: () => (logViewerFullUrl),
|
||||
reftest: () => (isReftest(selectedJob) ? reftestUrl : ''),
|
||||
selectedJob: () => (selectedJob),
|
||||
allFailures: () => (allFailures),
|
||||
crashSignatures: () => (crashSignatures),
|
||||
successCallback: () => (data) => {
|
||||
// Auto-classify this failure now that the bug has been filed
|
||||
// and we have a bug number
|
||||
addBug({ id: data.success });
|
||||
this.$rootScope.$evalAsync(
|
||||
this.$rootScope.$emit(
|
||||
thEvents.saveClassification));
|
||||
// Open the newly filed bug in a new tab or window for further editing
|
||||
window.open(getBugUrl(data.success));
|
||||
},
|
||||
},
|
||||
});
|
||||
pinJob(selectedJob);
|
||||
|
||||
modalInstance.opened.then(function () {
|
||||
window.setTimeout(() => modalInstance.initiate(), 0);
|
||||
this.setState({
|
||||
isBugFilerOpen: true,
|
||||
suggestion,
|
||||
});
|
||||
}
|
||||
|
||||
toggleBugFiler() {
|
||||
this.setState({ isBugFilerOpen: !this.state.isBugFilerOpen });
|
||||
}
|
||||
|
||||
bugFilerCallback(data) {
|
||||
const { addBug } = this.props;
|
||||
|
||||
addBug({ id: data.success });
|
||||
this.$rootScope.$evalAsync(this.$rootScope.$emit(thEvents.saveClassification));
|
||||
// Open the newly filed bug in a new tab or window for further editing
|
||||
window.open(getBugUrl(data.success));
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
jobLogUrls, logParseStatus, suggestions, errors,
|
||||
bugSuggestionsLoading, selectedJob, addBug,
|
||||
jobLogUrls, logParseStatus, suggestions, errors, logViewerFullUrl,
|
||||
bugSuggestionsLoading, selectedJob, addBug, reftestUrl,
|
||||
} = this.props;
|
||||
const { isBugFilerOpen, suggestion } = this.state;
|
||||
const logs = jobLogUrls;
|
||||
const jobLogsAllParsed = logs.every(jlu => (jlu.parse_status !== 'pending'));
|
||||
|
||||
|
@ -122,6 +112,18 @@ export default class FailureSummaryTab extends React.Component {
|
|||
</div>
|
||||
</div>}
|
||||
</ul>
|
||||
{isBugFilerOpen && <BugFiler
|
||||
isOpen={isBugFilerOpen}
|
||||
toggle={this.toggleBugFiler}
|
||||
suggestion={suggestion}
|
||||
suggestions={suggestions}
|
||||
fullLog={jobLogUrls[0].url}
|
||||
parsedLog={logViewerFullUrl}
|
||||
reftestUrl={isReftest(selectedJob) ? reftestUrl : ''}
|
||||
successCallback={this.bugFilerCallback}
|
||||
jobGroupName={selectedJob.job_group_name}
|
||||
notify={this.thNotify}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { fromNow } from 'taskcluster-client-web';
|
||||
import { WebAuth } from 'auth0-js';
|
||||
|
||||
import { loginCallbackUrl } from '../../helpers/url';
|
||||
|
||||
export const webAuth = new WebAuth({
|
||||
|
|
|
@ -1,415 +0,0 @@
|
|||
import $ from 'jquery';
|
||||
import _ from 'lodash';
|
||||
|
||||
import treeherder from '../treeherder';
|
||||
import { getApiUrl } from '../../helpers/url';
|
||||
|
||||
treeherder.controller('BugFilerCtrl', [
|
||||
'$scope', '$uibModalInstance', '$http', 'summary',
|
||||
'search_terms', 'fullLog', 'parsedLog', 'reftest', 'selectedJob',
|
||||
'allFailures', 'crashSignatures', 'successCallback', 'thNotify',
|
||||
function BugFilerCtrl(
|
||||
$scope, $uibModalInstance, $http, summary, search_terms,
|
||||
fullLog, parsedLog, reftest, selectedJob, allFailures,
|
||||
crashSignatures, successCallback, thNotify) {
|
||||
|
||||
const bzBaseUrl = 'https://bugzilla.mozilla.org/';
|
||||
const hgBaseUrl = 'https://hg.mozilla.org/';
|
||||
const dxrBaseUrl = 'https://dxr.mozilla.org/';
|
||||
|
||||
$scope.omittedLeads = ['TEST-UNEXPECTED-FAIL', 'PROCESS-CRASH', 'TEST-UNEXPECTED-ERROR', 'REFTEST ERROR'];
|
||||
|
||||
/**
|
||||
* 'enter' from the product search input should initiate the search
|
||||
*/
|
||||
$scope.productSearchEnter = function (ev) {
|
||||
if (ev.keyCode === 13) {
|
||||
$scope.findProduct();
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
**
|
||||
*/
|
||||
$scope.isReftest = function () {
|
||||
return reftest !== '';
|
||||
};
|
||||
|
||||
$scope.search_terms = search_terms;
|
||||
$scope.parsedLog = parsedLog;
|
||||
$scope.fullLog = fullLog;
|
||||
$scope.crashSignatures = crashSignatures.join('\n');
|
||||
if ($scope.isReftest()) {
|
||||
$scope.reftest = reftest;
|
||||
}
|
||||
|
||||
$scope.unhelpfulSummaryReason = function () {
|
||||
if (search_terms.length === 0) {
|
||||
return 'Selected failure does not contain any searchable terms.';
|
||||
}
|
||||
if (search_terms.every(term => !$scope.modalSummary.includes(term))) {
|
||||
return "Summary does not include the full text of any of the selected failure's search terms:";
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-fill the form with information/metadata from the failure
|
||||
*/
|
||||
$scope.initiate = function () {
|
||||
let thisFailure = '';
|
||||
|
||||
for (let i = 0; i < allFailures.length; i++) {
|
||||
for (let j = 0; j < $scope.omittedLeads.length; j++) {
|
||||
if (allFailures[i][0].search($scope.omittedLeads[j]) >= 0 && allFailures[i].length > 1) {
|
||||
allFailures[i].shift();
|
||||
}
|
||||
}
|
||||
|
||||
allFailures[i][0] = allFailures[i][0].replace('REFTEST TEST-UNEXPECTED-PASS', 'TEST-UNEXPECTED-PASS');
|
||||
|
||||
if (i !== 0) {
|
||||
thisFailure += '\n';
|
||||
}
|
||||
thisFailure += allFailures[i].join(' | ');
|
||||
}
|
||||
$scope.thisFailure = thisFailure;
|
||||
|
||||
$scope.findProduct();
|
||||
};
|
||||
|
||||
$uibModalInstance.parsedSummary = '';
|
||||
$uibModalInstance.initiate = $scope.initiate;
|
||||
$uibModalInstance.possibleFilename = '';
|
||||
|
||||
/*
|
||||
* Find the first thing in the summary line that looks like a filename.
|
||||
*/
|
||||
const findFilename = function (summary) {
|
||||
// Take left side of any reftest comparisons, as the right side is the reference file
|
||||
summary = summary.split('==')[0];
|
||||
// Take the leaf node of unix paths
|
||||
summary = summary.split('/').pop();
|
||||
// Take the leaf node of Windows paths
|
||||
summary = summary.split('\\').pop();
|
||||
// Remove leading/trailing whitespace
|
||||
summary = summary.trim();
|
||||
// If there's a space in what's remaining, take the first word
|
||||
summary = summary.split(' ')[0];
|
||||
return summary;
|
||||
};
|
||||
|
||||
/*
|
||||
* Remove extraneous junk from the start of the summary line
|
||||
* and try to find the failing test name from what's left
|
||||
*/
|
||||
$scope.parseSummary = function (summary) {
|
||||
// Strip out some extra stuff at the start of some failure paths
|
||||
let re = /file:\/\/\/.*?\/build\/tests\/reftest\/tests\//gi;
|
||||
summary = summary.replace(re, '');
|
||||
re = /\/home\/worker\/workspace\/build\/src\//gi;
|
||||
summary = summary.replace(re, '');
|
||||
re = /chrome:\/\/mochitests\/content\/a11y\//gi;
|
||||
summary = summary.replace(re, '');
|
||||
re = /\/home\/worker\/checkouts\/gecko\//gi;
|
||||
summary = summary.replace(re, '');
|
||||
re = /http:\/\/([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+):([0-9]+)\/tests\//gi;
|
||||
summary = summary.replace(re, '');
|
||||
re = /jetpack-package\//gi;
|
||||
summary = summary.replace(re, '');
|
||||
re = /xpcshell([-a-zA-Z0-9]+)?.ini:/gi;
|
||||
summary = summary.replace(re, '');
|
||||
summary = summary.replace('/_mozilla/', 'mozilla/tests/');
|
||||
// We don't want to include "REFTEST" when it's an unexpected pass
|
||||
summary = summary.replace('REFTEST TEST-UNEXPECTED-PASS', 'TEST-UNEXPECTED-PASS');
|
||||
|
||||
summary = summary.split(' | ');
|
||||
|
||||
// If the search_terms used for finding bug suggestions
|
||||
// contains any of the omittedLeads, that lead is needed
|
||||
// for the full string match, so don't omit it in this case.
|
||||
// If it's not needed, go ahead and omit it.
|
||||
for (let i = 0; i < $scope.omittedLeads.length; i++) {
|
||||
if ($scope.search_terms.length > 0 && summary.length > 1 &&
|
||||
!$scope.search_terms[0].includes($scope.omittedLeads[i]) &&
|
||||
summary[0].search($scope.omittedLeads[i]) >= 0) {
|
||||
summary.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Some of the TEST-FOO bits aren't removed from the summary,
|
||||
// so we sometimes end up with them instead of the test path here.
|
||||
const summaryName = summary[0].startsWith('TEST-') && summary.length > 1 ? summary[1] : summary[0];
|
||||
$uibModalInstance.possibleFilename = findFilename(summaryName);
|
||||
|
||||
return [summary, $uibModalInstance.possibleFilename];
|
||||
};
|
||||
|
||||
$uibModalInstance.parsedSummary = $scope.parseSummary(summary);
|
||||
let summaryString = $uibModalInstance.parsedSummary[0].join(' | ');
|
||||
if (selectedJob.job_group_name.toLowerCase().includes('reftest')) {
|
||||
const re = /layout\/reftests\//gi;
|
||||
summaryString = summaryString.replace(re, '');
|
||||
}
|
||||
$scope.modalSummary = 'Intermittent ' + summaryString;
|
||||
|
||||
// Add a product/component pair to suggestedProducts
|
||||
const addProduct = function (product) {
|
||||
// Don't allow duplicates to be added to the list
|
||||
if (!$scope.suggestedProducts.includes(product)) {
|
||||
$scope.suggestedProducts.push(product);
|
||||
$scope.selection.selectedProduct = $scope.suggestedProducts[0];
|
||||
}
|
||||
};
|
||||
|
||||
// Some job types are special, lets explicitly handle them.
|
||||
const injectProducts = function (fp) {
|
||||
if ($scope.suggestedProducts.length === 0) {
|
||||
const jg = selectedJob.job_group_name.toLowerCase();
|
||||
if (jg.includes('talos')) {
|
||||
addProduct('Testing :: Talos');
|
||||
}
|
||||
if (jg.includes('mochitest') && (fp.includes('webextensions/') || fp.includes('components/extensions'))) {
|
||||
addProduct('WebExtensions :: General');
|
||||
}
|
||||
if (jg.includes('mochitest') && fp.includes('webrtc/')) {
|
||||
addProduct('Core :: WebRTC');
|
||||
}
|
||||
}
|
||||
$scope.selection.selectedProduct = $scope.suggestedProducts[0];
|
||||
};
|
||||
|
||||
/*
|
||||
* Attempt to find a good product/component for this failure
|
||||
*/
|
||||
$scope.findProduct = function () {
|
||||
$scope.suggestedProducts = [];
|
||||
|
||||
// Look up product suggestions via Bugzilla's api
|
||||
const productSearch = $scope.productSearch;
|
||||
|
||||
if (productSearch) {
|
||||
$scope.searching = 'Bugzilla';
|
||||
$http.get(bzBaseUrl + 'rest/prod_comp_search/' + productSearch + '?limit=5').then(function (request) {
|
||||
const data = request.data;
|
||||
// We can't file unless product and component are provided, this api can return just product. Cut those out.
|
||||
for (let i = data.products.length - 1; i >= 0; i--) {
|
||||
if (!data.products[i].component) {
|
||||
data.products.splice(i, 1);
|
||||
}
|
||||
}
|
||||
$scope.searching = false;
|
||||
$scope.suggestedProducts = [];
|
||||
$scope.suggestedProducts = data.products.map((prod) => {
|
||||
if (prod.product && prod.component) {
|
||||
return prod.product + ' :: ' + prod.component;
|
||||
}
|
||||
return prod.product;
|
||||
});
|
||||
$scope.selection.selectedProduct = $scope.suggestedProducts[0];
|
||||
});
|
||||
} else {
|
||||
let failurePath = $uibModalInstance.parsedSummary[0][0];
|
||||
|
||||
// If the "TEST-UNEXPECTED-foo" isn't one of the omitted ones, use the next piece in the summary
|
||||
if (failurePath.includes('TEST-UNEXPECTED-')) {
|
||||
failurePath = $uibModalInstance.parsedSummary[0][1];
|
||||
$uibModalInstance.possibleFilename = findFilename(failurePath);
|
||||
}
|
||||
|
||||
// Try to fix up file paths for some job types.
|
||||
if (selectedJob.job_group_name.toLowerCase().includes('spidermonkey')) {
|
||||
failurePath = 'js/src/tests/' + failurePath;
|
||||
}
|
||||
if (selectedJob.job_group_name.toLowerCase().includes('videopuppeteer ')) {
|
||||
failurePath = failurePath.replace('FAIL ', '');
|
||||
failurePath = 'dom/media/test/external/external_media_tests/' + failurePath;
|
||||
}
|
||||
if (selectedJob.job_group_name.toLowerCase().includes('web platform')) {
|
||||
failurePath = failurePath.startsWith('mozilla/tests') ?
|
||||
`testing/web-platform/${failurePath}` :
|
||||
`testing/web-platform/tests/${failurePath}`;
|
||||
}
|
||||
|
||||
// Search mercurial's moz.build metadata to find products/components
|
||||
$scope.searching = 'Mercurial';
|
||||
$http.get(`${hgBaseUrl}mozilla-central/json-mozbuildinfo?p=${failurePath}`).then(function (firstRequest) {
|
||||
if (firstRequest.data.aggregate && firstRequest.data.aggregate.recommended_bug_component) {
|
||||
const suggested = firstRequest.data.aggregate.recommended_bug_component;
|
||||
addProduct(suggested[0] + ' :: ' + suggested[1]);
|
||||
}
|
||||
|
||||
$scope.searching = false;
|
||||
|
||||
// Make an attempt to find the file path via a dxr file search
|
||||
if ($scope.suggestedProducts.length === 0 && $uibModalInstance.possibleFilename.length > 4) {
|
||||
$scope.searching = 'DXR & Mercurial';
|
||||
const dxrlink = `${dxrBaseUrl}mozilla-central/search?q=file:${$uibModalInstance.possibleFilename}&redirect=false&limit=5`;
|
||||
// Bug 1358328 - We need to override headers here until DXR returns JSON with the default Accept header
|
||||
$http.get(dxrlink, { headers: {
|
||||
Accept: 'application/json',
|
||||
} }).then((secondRequest) => {
|
||||
const results = secondRequest.data.results;
|
||||
let resultsCount = results.length;
|
||||
// If the search returns too many results, this probably isn't a good search term, so bail
|
||||
if (resultsCount === 0) {
|
||||
$scope.searching = false;
|
||||
injectProducts(failurePath);
|
||||
}
|
||||
results.forEach((result) => {
|
||||
$scope.searching = 'DXR & Mercurial';
|
||||
$http.get(`${hgBaseUrl}mozilla-central/json-mozbuildinfo?p=${result.path}`)
|
||||
.then((thirdRequest) => {
|
||||
if (thirdRequest.data.aggregate && thirdRequest.data.aggregate.recommended_bug_component) {
|
||||
const suggested = thirdRequest.data.aggregate.recommended_bug_component;
|
||||
addProduct(suggested[0] + ' :: ' + suggested[1]);
|
||||
}
|
||||
// Only get rid of the throbber when all of these searches have completed
|
||||
resultsCount -= 1;
|
||||
if (resultsCount === 0) {
|
||||
$scope.searching = false;
|
||||
injectProducts(failurePath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
injectProducts(failurePath);
|
||||
}
|
||||
|
||||
$scope.selection.selectedProduct = $scope.suggestedProducts[0];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Same as clicking outside of the modal, but with a nice button-clicking feel...
|
||||
*/
|
||||
$scope.cancelFiler = function () {
|
||||
$uibModalInstance.dismiss('cancel');
|
||||
};
|
||||
|
||||
$scope.checkedLogLinks = {
|
||||
parsedLog: $scope.parsedLog,
|
||||
fullLog: $scope.fullLog,
|
||||
reftest: $scope.reftest,
|
||||
};
|
||||
|
||||
$scope.isIntermittent = true;
|
||||
|
||||
/*
|
||||
* Actually send the gathered information to bugzilla.
|
||||
*/
|
||||
$scope.submitFiler = function () {
|
||||
const summarystring = $scope.modalSummary;
|
||||
let productString = '';
|
||||
let componentString = '';
|
||||
|
||||
$scope.toggleForm(true);
|
||||
|
||||
if ($scope.modalSummary.length > 255) {
|
||||
thNotify.send('Please ensure the summary is no more than 255 characters', 'danger');
|
||||
$scope.toggleForm(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.selection.selectedProduct) {
|
||||
const prodParts = $scope.selection.selectedProduct.split(' :: ');
|
||||
productString += prodParts[0];
|
||||
componentString += prodParts[1];
|
||||
} else {
|
||||
thNotify.send('Please select (or search and select) a product/component pair to continue', 'danger');
|
||||
$scope.toggleForm(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let descriptionStrings = Object.values($scope.checkedLogLinks).reduce((result, link) => {
|
||||
if (link) {
|
||||
result = result + link + '\n\n';
|
||||
}
|
||||
return result;
|
||||
}, '');
|
||||
if ($scope.modalComment) {
|
||||
descriptionStrings += $scope.modalComment;
|
||||
}
|
||||
|
||||
const keywords = $scope.isIntermittent ? ['intermittent-failure'] : [];
|
||||
|
||||
let severity = 'normal';
|
||||
const priority = 'P5';
|
||||
const blocks = $scope.modalBlocks;
|
||||
const dependsOn = $scope.modalDependsOn;
|
||||
const seeAlso = $scope.modalSeeAlso;
|
||||
const crashSignature = $scope.crashSignatures;
|
||||
if (crashSignature.length > 0) {
|
||||
keywords.push('crash');
|
||||
severity = 'critical';
|
||||
}
|
||||
|
||||
// Fetch product information from bugzilla to get version numbers, then submit the new bug
|
||||
// Only request the versions because some products take quite a long time to fetch the full object
|
||||
$http.get(bzBaseUrl + 'rest/product/' + productString + '?include_fields=versions')
|
||||
.then(function (response) {
|
||||
const productJSON = response.data;
|
||||
const productObject = productJSON.products[0];
|
||||
|
||||
// Find the newest version for the product that is_active
|
||||
const version = _.findLast(productObject.versions, function (version) {
|
||||
return version.is_active === true;
|
||||
});
|
||||
|
||||
return $http({
|
||||
url: getApiUrl('/bugzilla/create_bug/'),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
data: {
|
||||
product: productString,
|
||||
component: componentString,
|
||||
summary: summarystring,
|
||||
keywords: keywords,
|
||||
version: version.name,
|
||||
blocks: blocks,
|
||||
depends_on: dependsOn,
|
||||
see_also: seeAlso,
|
||||
crash_signature: crashSignature,
|
||||
severity: severity,
|
||||
priority: priority,
|
||||
comment: descriptionStrings,
|
||||
comment_tags: 'treeherder',
|
||||
},
|
||||
});
|
||||
})
|
||||
.then((response) => {
|
||||
const data = response.data;
|
||||
if (data.failure) {
|
||||
const error = JSON.parse(data.failure.join(''));
|
||||
thNotify.send('Bugzilla error: ' + error.message, 'danger', { sticky: true });
|
||||
$scope.toggleForm(false);
|
||||
} else {
|
||||
successCallback(data);
|
||||
$scope.cancelFiler();
|
||||
}
|
||||
})
|
||||
.catch((response) => {
|
||||
let failureString = 'Bug Filer API returned status ' + response.status + ' (' + response.statusText + ')';
|
||||
if (response.data && response.data.failure) {
|
||||
failureString += '\n\n' + response.data.failure;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
failureString += '\n\nAuthentication failed. Has your Treeherder session expired?';
|
||||
}
|
||||
thNotify.send(failureString, 'danger');
|
||||
$scope.toggleForm(false);
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
* Disable or enable form elements as needed at various points in the submission process
|
||||
*/
|
||||
$scope.toggleForm = function (disabled) {
|
||||
$(':input', '#modalForm').attr('disabled', disabled);
|
||||
};
|
||||
},
|
||||
]);
|
|
@ -47,7 +47,7 @@ treeherder.factory('thNotify', [
|
|||
severity,
|
||||
created: Date.now(),
|
||||
};
|
||||
thNotify.notifications.unshift(notification);
|
||||
$timeout(thNotify.notifications.unshift(notification));
|
||||
thNotify.storedNotifications.unshift(notification);
|
||||
thNotify.storedNotifications.splice(40);
|
||||
localStorage.setItem('notifications', JSON.stringify(thNotify.storedNotifications));
|
||||
|
|
|
@ -1,103 +0,0 @@
|
|||
<div class="modal-header">
|
||||
<h4>Intermittent Bug Filer</h4>
|
||||
<button type="button" class="close" ng-click="cancelFiler()"><span aria-hidden="true">×</span><span class="sr-only">Close</span></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="modalForm">
|
||||
<input name="modalProductFinderSearch" id="modalProductFinderSearch" ng-keydown="productSearchEnter($event)"
|
||||
ng-model="productSearch" type="text" placeholder="Firefox" uib-tooltip="Manually search for a product" />
|
||||
<button name="modalProductFinderButton" id="modalProductFinderButton"
|
||||
type="button" ng-click="findProduct()" prevent-default-on-left-click>Find Product</button>
|
||||
<div>
|
||||
<div id="productSearchSpinner" ng-show="searching">
|
||||
<span class="fa fa-spinner fa-pulse th-spinner-lg"></span>
|
||||
Searching {{searching}}
|
||||
</div>
|
||||
<fieldset id="suggestedProducts" ng-init="selection={}">
|
||||
<div ng-repeat="product in suggestedProducts">
|
||||
<input type="radio" value="{{product}}"
|
||||
ng-model="selection.selectedProduct"
|
||||
name="productGroup" id="modalProductSuggestion{{$id}}"/>
|
||||
<label for="modalProductSuggestion{{$id}}">{{product}}</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
<div id="failureSummaryGroup" class="collapsed">
|
||||
<div id="unhelpfulSummaryReason" ng-show="unhelpfulSummaryReason()">
|
||||
<div>
|
||||
<span class="fa fa-info-circle" uib-tooltip="This can cause poor bug suggestions to be generated"></span>
|
||||
Warning: {{unhelpfulSummaryReason()}}
|
||||
</div>
|
||||
<div ng-repeat="term in search_terms">
|
||||
{{term}}
|
||||
</div>
|
||||
</div>
|
||||
<label id="modalSummarylabel" for="modalSummary">Summary:</label>
|
||||
<input id="modalSummary" type="text" placeholder="Intermittent..." pattern=".{0,255}"
|
||||
ng-model="modalSummary" ng-model-options="{allowInvalid:true}" />
|
||||
<span id="modalSummaryLength" ng-bind="modalSummary.length" />
|
||||
<a ng-class="{'filersummary-open-btn': isFilerSummaryVisible}" prevent-default-on-left-click>
|
||||
<i ng-click="toggleFilerSummaryVisibility()" ng-hide="isFilerSummaryVisible"
|
||||
class="fa fa-chevron-right" uib-tooltip="Show all failure lines for this job">
|
||||
</i>
|
||||
<span ng-show="isFilerSummaryVisible">
|
||||
<i ng-click="toggleFilerSummaryVisibility()" class="fa fa-chevron-down" uib-tooltip="Hide all failure lines for this job"></i>
|
||||
<textarea id="modalFailureList">{{thisFailure}}</textarea>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="modalLogLinkCheckboxes">
|
||||
<label>
|
||||
<input id="modalParsedLog" type="checkbox"
|
||||
ng-model="checkedLogLinks.parsedLog"
|
||||
ng-true-value="'{{parsedLog}}'"/>
|
||||
<a target="_blank" rel="noopener" href="{{ parsedLog }}">Include Parsed Log Link</a>
|
||||
</label><br/>
|
||||
<label>
|
||||
<input id="modalFullLog" type="checkbox"
|
||||
ng-model="checkedLogLinks.fullLog"
|
||||
ng-true-value="'{{fullLog}}'"/>
|
||||
<a target="_blank" rel="noopener" href="{{ fullLog }}">Include Full Log Link</a>
|
||||
</label><br/>
|
||||
<label id="modalReftestLogLabel" ng-if="isReftest()">
|
||||
<input id="modalReftestLog" type="checkbox"
|
||||
ng-model="checkedLogLinks.reftest"
|
||||
ng-true-value="'{{reftest}}'"/>
|
||||
<a target="_blank" rel="noopener" href="{{ reftest }}">Include Reftest Viewer Link</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="modalCommentDiv">
|
||||
<label id="modalCommentlabel" for="modalComment">Comment:</label>
|
||||
<textarea ng-model="modalComment" id="modalComment" type="textarea" placeholder=""></textarea>
|
||||
</div>
|
||||
|
||||
<div id="modalExtras">
|
||||
<label>
|
||||
<input id="modalIsIntermittent"
|
||||
ng-model="isIntermittent" type="checkbox"
|
||||
ng-checked="true" />
|
||||
This is an intermittent failure
|
||||
</label>
|
||||
|
||||
<div id="modalRelatedBugs">
|
||||
<input type="text" ng-model="modalBlocks" placeholder="Blocks" uib-tooltip="Comma-separated list of bugs" tooltip-placement="bottom" />
|
||||
<input type="text" ng-model="modalDependsOn" placeholder="Depends on" uib-tooltip="Comma-separated list of bugs" tooltip-placement="bottom" />
|
||||
<input type="text" ng-model="modalSeeAlso" placeholder="See also" uib-tooltip="Comma-separated list of bugs" tooltip-placement="bottom" />
|
||||
</div>
|
||||
|
||||
<div ng-show="crashSignatures.length" id="modalCrashSignatureDiv">
|
||||
<label id="modalCrashSignatureLabel" for="modalCrashSignature">Signature:</label>
|
||||
<textarea id="modalCrashSignature" ng-model="crashSignatures" maxlength="2048"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button name="modalCancelButton" id="modalCancelButton" type="button" ng-click="cancelFiler()"> Cancel </button>
|
||||
<button name="modalSubmitButton" id="modalSubmitButton" type="button" ng-click="submitFiler()"> Submit Bug </button>
|
||||
</div>
|
Загрузка…
Ссылка в новой задаче