Bug 1465987 - Convert the BugFiler to ReactJS (#3878)

This commit is contained in:
Cameron Dawson 2018-08-06 10:54:00 -07:00 коммит произвёл GitHub
Родитель f4fa05ea3f
Коммит ed2e800d90
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
13 изменённых файлов: 943 добавлений и 937 удалений

Просмотреть файл

@ -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;
pinJob(selectedJob);
this.setState({
isBugFilerOpen: true,
suggestion,
});
}
toggleBugFiler() {
this.setState({ isBugFilerOpen: !this.state.isBugFilerOpen });
}
bugFilerCallback(data) {
const { addBug } = 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));
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);
});
}
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">&times;</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>