Web Inspector: Audit: allow audits to be enabled/disabled
[WebKit-https.git] / Source / WebInspectorUI / UserInterface / Controllers / AuditManager.js
1 /*
2  * Copyright (C) 2018 Apple Inc. All rights reserved.
3  *
4  * Redistribution and use in source and binary forms, with or without
5  * modification, are permitted provided that the following conditions
6  * are met:
7  * 1. Redistributions of source code must retain the above copyright
8  *    notice, this list of conditions and the following disclaimer.
9  * 2. Redistributions in binary form must reproduce the above copyright
10  *    notice, this list of conditions and the following disclaimer in the
11  *    documentation and/or other materials provided with the distribution.
12  *
13  * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
14  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
15  * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
16  * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
17  * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18  * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19  * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20  * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21  * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22  * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
23  * THE POSSIBILITY OF SUCH DAMAGE.
24  */
25
26 WI.AuditManager = class AuditManager extends WI.Object
27 {
28     constructor()
29     {
30         super();
31
32         this._tests = [];
33         this._results = [];
34
35         this._runningState = WI.AuditManager.RunningState.Inactive;
36         this._runningTests = [];
37
38         this._disabledDefaultTestsSetting = new WI.Setting("audit-disabled-default-tests", []);
39
40         WI.Frame.addEventListener(WI.Frame.Event.MainResourceDidChange, this._handleFrameMainResourceDidChange, this);
41     }
42
43     static synthesizeError(message)
44     {
45         let consoleMessage = new WI.ConsoleMessage(WI.mainTarget, WI.ConsoleMessage.MessageSource.Other, WI.ConsoleMessage.MessageLevel.Error, WI.UIString("Audit error: %s").format(message));
46         consoleMessage.shouldRevealConsole = true;
47
48         WI.consoleLogViewController.appendConsoleMessage(consoleMessage);
49     }
50
51     // Public
52
53     get tests() { return this._tests; }
54     get results() { return this._results; }
55     get runningState() { return this._runningState; }
56
57     get editing()
58     {
59         return this._runningState === WI.AuditManager.RunningState.Disabled;
60     }
61
62     set editing(editing)
63     {
64         console.assert(this._runningState === WI.AuditManager.RunningState.Disabled || this._runningState === WI.AuditManager.RunningState.Inactive);
65         if (this._runningState !== WI.AuditManager.RunningState.Disabled && this._runningState !== WI.AuditManager.RunningState.Inactive)
66             return;
67
68         let runningState = editing ? WI.AuditManager.RunningState.Disabled : WI.AuditManager.RunningState.Inactive;
69         console.assert(runningState !== this._runningState);
70         if (runningState === this._runningState)
71             return;
72
73         this._runningState = runningState;
74
75         this.dispatchEventToListeners(WI.AuditManager.Event.EditingChanged);
76
77         if (!this.editing) {
78             WI.objectStores.audits.clear();
79
80             let disabledDefaultTests = [];
81             let saveDisabledDefaultTest = (test) => {
82                 if (test.disabled)
83                     disabledDefaultTests.push(test.name);
84
85                 if (test instanceof WI.AuditTestGroup) {
86                     for (let child of test.tests)
87                         saveDisabledDefaultTest(child);
88                 }
89             };
90
91             for (let test of this._tests) {
92                 if (test.__default)
93                     saveDisabledDefaultTest(test);
94                 else
95                     WI.objectStores.audits.addObject(test);
96             }
97
98             this._disabledDefaultTestsSetting.value = disabledDefaultTests;
99         }
100     }
101
102     async start(tests)
103     {
104         console.assert(this._runningState === WI.AuditManager.RunningState.Inactive);
105         if (this._runningState !== WI.AuditManager.RunningState.Inactive)
106             return;
107
108         if (tests && tests.length)
109             tests = tests.filter((test) => typeof test === "object" && test instanceof WI.AuditTestBase);
110         else
111             tests = this._tests;
112
113         if (!tests.length)
114             return;
115
116         let mainResource = WI.networkManager.mainFrame.mainResource;
117
118         this._runningState = WI.AuditManager.RunningState.Active;
119         this._runningTests = tests;
120         for (let test of this._runningTests)
121             test.clearResult();
122
123         this.dispatchEventToListeners(WI.AuditManager.Event.TestScheduled);
124
125         await Promise.chain(this._runningTests.map((test) => () => this._runningState === WI.AuditManager.RunningState.Active ? test.start() : null));
126
127         let result = this._runningTests.map((test) => test.result).filter((result) => !!result);
128
129         this._runningState = WI.AuditManager.RunningState.Inactive;
130         this._runningTests = [];
131
132         this._addResult(result);
133
134         if (mainResource !== WI.networkManager.mainFrame.mainResource) {
135             // Navigated while tests were running.
136             for (let test of this._tests)
137                 test.clearResult();
138         }
139     }
140
141     stop()
142     {
143         console.assert(this._runningState === WI.AuditManager.RunningState.Active);
144         if (this._runningState !== WI.AuditManager.RunningState.Active)
145             return;
146
147         this._runningState = WI.AuditManager.RunningState.Stopping;
148
149         for (let test of this._runningTests)
150             test.stop();
151     }
152
153     async processJSON({json, error})
154     {
155         if (error) {
156             WI.AuditManager.synthesizeError(error);
157             return;
158         }
159
160         let object = await WI.AuditTestGroup.fromPayload(json) || await WI.AuditTestCase.fromPayload(json);
161         if (!object) {
162             object = await WI.AuditTestGroupResult.fromPayload(json) || await WI.AuditTestCaseResult.fromPayload(json);
163             if (!object) {
164                 WI.AuditManager.synthesizeError(WI.UIString("invalid JSON."));
165                 return;
166             }
167         }
168
169         if (object instanceof WI.AuditTestBase) {
170             this._addTest(object);
171             WI.objectStores.audits.addObject(object);
172         } else if (object instanceof WI.AuditTestResultBase)
173             this._addResult(object);
174
175         WI.showRepresentedObject(object);
176     }
177
178     export(object)
179     {
180         console.assert(object instanceof WI.AuditTestCase || object instanceof WI.AuditTestGroup || object instanceof WI.AuditTestCaseResult || object instanceof WI.AuditTestGroupResult, object);
181
182         let filename = object.name;
183         if (object instanceof WI.AuditTestResultBase)
184             filename = WI.UIString("%s Result").format(filename);
185
186         let url = "web-inspector:///" + encodeURI(filename) + ".json";
187
188         WI.FileUtilities.save({
189             url,
190             content: JSON.stringify(object),
191             forceSaveAs: true,
192         });
193     }
194
195     loadStoredTests()
196     {
197         if (this._tests.length)
198             return;
199
200         WI.objectStores.audits.getAll().then(async (tests) => {
201             for (let payload of tests) {
202                 let test = await WI.AuditTestGroup.fromPayload(payload) || await WI.AuditTestCase.fromPayload(payload);
203                 if (!test)
204                     continue;
205
206                 const key = null;
207                 WI.objectStores.audits.associateObject(test, key, payload);
208
209                 this._addTest(test);
210             }
211
212             this.addDefaultTestsIfNeeded();
213         });
214     }
215
216     removeTest(test)
217     {
218         this._tests.remove(test);
219
220         this.dispatchEventToListeners(WI.AuditManager.Event.TestRemoved, {test});
221
222         if (!test.__default)
223             WI.objectStores.audits.deleteObject(test);
224     }
225
226     // Private
227
228     _addTest(test)
229     {
230         this._tests.push(test);
231
232         this.dispatchEventToListeners(WI.AuditManager.Event.TestAdded, {test});
233     }
234
235     _addResult(result)
236     {
237         if (!result || (Array.isArray(result) && !result.length))
238             return;
239
240         this._results.push(result);
241
242         this.dispatchEventToListeners(WI.AuditManager.Event.TestCompleted, {
243             result,
244             index: this._results.length - 1,
245         });
246     }
247
248     _handleFrameMainResourceDidChange(event)
249     {
250         if (!event.target.isMainFrame())
251             return;
252
253         if (this._runningState === WI.AuditManager.RunningState.Active)
254             this.stop();
255         else {
256             for (let test of this._tests)
257                 test.clearResult();
258         }
259     }
260
261     addDefaultTestsIfNeeded()
262     {
263         if (this._tests.length)
264             return;
265
266         const defaultTests = [
267             new WI.AuditTestGroup(WI.UIString("Demo Audit"), [
268                 new WI.AuditTestGroup(WI.UIString("Result Levels"), [
269                     new WI.AuditTestCase(`level-pass`, `function() { return {level: "pass"}; }`, {description: WI.UIString("This is what the result of a passing test with no data looks like.")}),
270                     new WI.AuditTestCase(`level-warn`, `function() { return {level: "warn"}; }`, {description: WI.UIString("This is what the result of a warning test with no data looks like.")}),
271                     new WI.AuditTestCase(`level-fail`, `function() { return {level: "fail"}; }`, {description: WI.UIString("This is what the result of a failing test with no data looks like.")}),
272                     new WI.AuditTestCase(`level-error`, `function() { return {level: "error"}; }`, {description: WI.UIString("This is what the result of a test that threw an error with no data looks like.")}),
273                     new WI.AuditTestCase(`level-unsupported`, `function() { return {level: "unsupported"}; }`, {description: WI.UIString("This is what the result of an unsupported test with no data looks like.")}),
274                 ], {description: WI.UIString("These are all of the different test result levels.")}),
275                 new WI.AuditTestGroup(WI.UIString("Result Data"), [
276                     new WI.AuditTestCase(`data-domNodes`, `function() { return {domNodes: [document.body], level: "pass"}; }`, {description: WI.UIString("This is an example of how result DOM nodes are shown. It will pass with the <body> element.")}),
277                     new WI.AuditTestCase(`data-domAttributes`, `function() { return {domNodes: Array.from(document.querySelectorAll("[id]")), domAttributes: ["id"], level: "pass"}; }`, {description: WI.UIString("This is an example of how result DOM nodes are shown. It will pass with all elements with an id attribute.")}),
278                     new WI.AuditTestCase(`data-errors`, `function() { throw Error("this error was thrown from inside the audit test code."); }`, {description: WI.UIString("This is an example of how errors are shown. The error was thrown manually, but execution errors will appear in the same way.")}),
279                 ], {description: WI.UIString("These are all of the different types of data that can be returned with the test result.")}),
280             ], {description: WI.UIString("These tests serve as a demonstration of the functionality and structure of audits.")}),
281             new WI.AuditTestGroup(WI.UIString("Accessibility"), [
282                 new WI.AuditTestGroup(WI.UIString("Attributes"), [
283                     new WI.AuditTestCase(`img-alt`, `function() { let domNodes = Array.from(document.getElementsByTagName("img")).filter((img) => !img.alt || !img.alt.length); return { level: domNodes.length ? "fail" : "pass", domNodes, domAttributes: ["alt"] }; }`, {description: WI.UIString("Ensure <img> elements have alternate text.")}),
284                     new WI.AuditTestCase(`area-alt`, `function() { let domNodes = Array.from(document.getElementsByTagName("area")).filter((area) => !area.alt || !area.alt.length); return { level: domNodes.length ? "fail" : "pass", domNodes, domAttributes: ["alt"] }; }`, {description: WI.UIString("Ensure <area> elements have alternate text.")}),
285                     new WI.AuditTestCase(`valid-tabindex`, `function() { let domNodes = Array.from(document.querySelectorAll("*[tabindex]")) .filter((node) => { let tabindex = node.getAttribute("tabindex"); if (!tabindex) return false; tabindex = parseInt(tabindex); return isNaN(tabindex) || (tabindex !== 0 && tabindex !== -1); }); return { level: domNodes.length ? "fail" : "pass", domNodes, domAttributes: ["tabindex"] }; }`, {description: WI.UIString("Ensure tabindex is a number.")}),
286                     new WI.AuditTestCase(`frame-title`, `function() { let domNodes = Array.from(document.querySelectorAll("iframe, frame")) .filter((node) => { let title = node.getAttribute("title"); return !title || !title.trim().length; }); return { level: domNodes.length ? "fail" : "pass", domNodes, domAttributes: ["title"] }; }`, {description: WI.UIString("Ensure <frame> elements have a title.")}),
287                     new WI.AuditTestCase(`hidden-body`, `function() { let domNodes = Array.from(document.querySelectorAll("body[hidden]")).filter((body) => body.hidden); return { level: domNodes.length ? "fail" : "pass", domNodes, domAttributes: ["hidden"] }; }`, {description: WI.UIString("Ensure hidden=true is not present on the <body>.")}),
288                     new WI.AuditTestCase(`meta-refresh`, `function() { let domNodes = Array.from(document.querySelectorAll("meta[http-equiv=refresh]")); return { level: domNodes.length ? "warn" : "pass", domNodes, domAttributes: ["http-equiv"] }; }`, {description: WI.UIString("Ensure <meta http-equiv=refresh> is not used.")}),
289                 ], {description: WI.UIString("Tests for element attribute accessibility issues.")}),
290                 new WI.AuditTestGroup(WI.UIString("Elements"), [
291                     new WI.AuditTestCase(`blink`, `function() { let domNodes = Array.from(document.getElementsByTagName("blink")); return { level: domNodes.length ? "warn" : "pass", domNodes }; }`, {description: WI.UIString("Ensure <blink> is not used.")}),
292                     new WI.AuditTestCase(`marquee`, `function() { let domNodes = Array.from(document.getElementsByTagName("marquee")); return { level: domNodes.length ? "warn" : "pass", domNodes }; }`, {description: WI.UIString("Ensure <marquee> is not used.")}),
293                     new WI.AuditTestCase(`dlitem`, `function() { function check(node) { if (!node) { return false; } if (node.nodeName === "DD") { return true; } return check(node.parentNode); } let domNodes = Array.from(document.querySelectorAll("dt, dd")).filter(check); return { level: domNodes.length ? "warn" : "pass", domNodes }; }`, {description: WI.UIString("Ensure <dt> and <dd> elements are contained by a <dl>.")}),
294                 ], {description: WI.UIString("Tests for element accessibility issues.")}),
295                 new WI.AuditTestGroup(WI.UIString("Forms"), [
296                     new WI.AuditTestCase(`one-legend`, `function() { let formLegendsMap = Array.from(document.querySelectorAll("form legend")).reduce((accumulator, node) => { let existing = accumulator.get(node.form); if (!existing) { existing = []; accumulator.set(node.form, existing); } existing.push(node); return accumulator; }, new Map); let domNodes = Array.from(formLegendsMap.values()).reduce((accumulator, legends) => accumulator.concat(legends), []); return { level: domNodes.length ? "warn" : "pass", domNodes }; }`, {description: WI.UIString("Ensure exactly one <legend> exists per <form>.")}),
297                     new WI.AuditTestCase(`legend-first-child`, `function() { let domNodes = Array.from(document.querySelectorAll("form > legend:not(:first-child)")); return { level: domNodes.length ? "warn" : "pass", domNodes }; }`, {description: WI.UIString("Ensure that the <legend> is the first child in the <form>.")}),
298                     new WI.AuditTestCase(`form-input`, `function() { let domNodes = Array.from(document.getElementsByTagName("form")) .filter(node => !node.elements.length); return { level: domNodes.length ? "warn" : "pass", domNodes }; }`, {description: WI.UIString("Ensure <form>s have at least one input.")}),
299                 ], {description: WI.UIString("Tests the accessibility of form elements.")}),
300             ], {description: WI.UIString("Tests for ways to improve accessibility.")}),
301         ];
302
303         let checkDisabledDefaultTest = (test) => {
304             if (this._disabledDefaultTestsSetting.value.includes(test.name))
305                 test.disabled = true;
306
307             if (test instanceof WI.AuditTestGroup) {
308                 for (let child of test.tests)
309                     checkDisabledDefaultTest(child);
310             }
311         };
312
313         for (let test of defaultTests) {
314             checkDisabledDefaultTest(test);
315
316             test.__default = true;
317             this._addTest(test);
318         }
319     }
320 };
321
322 WI.AuditManager.RunningState = {
323     Disabled: "disabled",
324     Inactive: "inactive",
325     Active: "active",
326     Stopping: "stopping",
327 };
328
329 WI.AuditManager.Event = {
330     EditingChanged: "audit-manager-editing-changed",
331     TestAdded: "audit-manager-test-added",
332     TestCompleted: "audit-manager-test-completed",
333     TestRemoved: "audit-manager-test-removed",
334     TestScheduled: "audit-manager-test-scheduled",
335 };