815212d83dd4da48e18b5fa7592be4be3ffd187a
[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
44
45     static synthesizeWarning(message)
46     {
47         message = WI.UIString("Audit Warning: %s").format(message);
48
49         if (window.InspectorTest) {
50             console.warn(message);
51             return;
52         }
53
54         let consoleMessage = new WI.ConsoleMessage(WI.mainTarget, WI.ConsoleMessage.MessageSource.Other, WI.ConsoleMessage.MessageLevel.Warning, message);
55         consoleMessage.shouldRevealConsole = true;
56
57         WI.consoleLogViewController.appendConsoleMessage(consoleMessage);
58     }
59
60     static synthesizeError(message)
61     {
62         message = WI.UIString("Audit Error: %s").format(message);
63
64         if (window.InspectorTest) {
65             console.error(message);
66             return;
67         }
68
69         let consoleMessage = new WI.ConsoleMessage(WI.mainTarget, WI.ConsoleMessage.MessageSource.Other, WI.ConsoleMessage.MessageLevel.Error, message);
70         consoleMessage.shouldRevealConsole = true;
71
72         WI.consoleLogViewController.appendConsoleMessage(consoleMessage);
73     }
74
75     // Public
76
77     get tests() { return this._tests; }
78     get results() { return this._results; }
79     get runningState() { return this._runningState; }
80
81     get editing()
82     {
83         return this._runningState === WI.AuditManager.RunningState.Disabled;
84     }
85
86     set editing(editing)
87     {
88         console.assert(this._runningState === WI.AuditManager.RunningState.Disabled || this._runningState === WI.AuditManager.RunningState.Inactive);
89         if (this._runningState !== WI.AuditManager.RunningState.Disabled && this._runningState !== WI.AuditManager.RunningState.Inactive)
90             return;
91
92         let runningState = editing ? WI.AuditManager.RunningState.Disabled : WI.AuditManager.RunningState.Inactive;
93         console.assert(runningState !== this._runningState);
94         if (runningState === this._runningState)
95             return;
96
97         this._runningState = runningState;
98
99         this.dispatchEventToListeners(WI.AuditManager.Event.EditingChanged);
100
101         if (!this.editing) {
102             WI.objectStores.audits.clear();
103
104             let disabledDefaultTests = [];
105             let saveDisabledDefaultTest = (test) => {
106                 if (test.disabled)
107                     disabledDefaultTests.push(test.name);
108
109                 if (test instanceof WI.AuditTestGroup) {
110                     for (let child of test.tests)
111                         saveDisabledDefaultTest(child);
112                 }
113             };
114
115             for (let test of this._tests) {
116                 if (test.__default)
117                     saveDisabledDefaultTest(test);
118                 else
119                     WI.objectStores.audits.addObject(test);
120             }
121
122             this._disabledDefaultTestsSetting.value = disabledDefaultTests;
123         }
124     }
125
126     async start(tests)
127     {
128         console.assert(this._runningState === WI.AuditManager.RunningState.Inactive);
129         if (this._runningState !== WI.AuditManager.RunningState.Inactive)
130             return;
131
132         if (tests && tests.length)
133             tests = tests.filter((test) => typeof test === "object" && test instanceof WI.AuditTestBase);
134         else
135             tests = this._tests;
136
137         console.assert(tests.length);
138         if (!tests.length)
139             return;
140
141         let mainResource = WI.networkManager.mainFrame.mainResource;
142
143         this._runningState = WI.AuditManager.RunningState.Active;
144         this._runningTests = tests;
145         for (let test of this._runningTests)
146             test.clearResult();
147
148         this.dispatchEventToListeners(WI.AuditManager.Event.TestScheduled);
149
150         await Promise.chain(this._runningTests.map((test) => async () => {
151             if (this._runningState !== WI.AuditManager.RunningState.Active)
152                 return;
153
154             if (InspectorBackend.domains.Audit)
155                 await AuditAgent.setup();
156
157             await test.start();
158
159             if (InspectorBackend.domains.Audit)
160                 await AuditAgent.teardown();
161         }));
162
163         let result = this._runningTests.map((test) => test.result).filter((result) => !!result);
164
165         this._runningState = WI.AuditManager.RunningState.Inactive;
166         this._runningTests = [];
167
168         this._addResult(result);
169
170         if (mainResource !== WI.networkManager.mainFrame.mainResource) {
171             // Navigated while tests were running.
172             for (let test of this._tests)
173                 test.clearResult();
174         }
175     }
176
177     stop()
178     {
179         console.assert(this._runningState === WI.AuditManager.RunningState.Active);
180         if (this._runningState !== WI.AuditManager.RunningState.Active)
181             return;
182
183         this._runningState = WI.AuditManager.RunningState.Stopping;
184
185         for (let test of this._runningTests)
186             test.stop();
187     }
188
189     async processJSON({json, error})
190     {
191         if (error) {
192             WI.AuditManager.synthesizeError(error);
193             return;
194         }
195
196         if (typeof json !== "object" || json === null) {
197             WI.AuditManager.synthesizeError(WI.UIString("invalid JSON"));
198             return;
199         }
200
201         if (json.type !== WI.AuditTestCase.TypeIdentifier && json.type !== WI.AuditTestGroup.TypeIdentifier
202             && json.type !== WI.AuditTestCaseResult.TypeIdentifier && json.type !== WI.AuditTestGroupResult.TypeIdentifier) {
203             WI.AuditManager.synthesizeError(WI.UIString("unknown %s \u0022%s\u0022").format(WI.unlocalizedString("type"), json.type));
204             return;
205         }
206
207         let object = await WI.AuditTestGroup.fromPayload(json) || await WI.AuditTestCase.fromPayload(json) || await WI.AuditTestGroupResult.fromPayload(json) || await WI.AuditTestCaseResult.fromPayload(json);
208         if (!object)
209             return;
210
211         if (object instanceof WI.AuditTestBase) {
212             this._addTest(object);
213             WI.objectStores.audits.addObject(object);
214         } else if (object instanceof WI.AuditTestResultBase)
215             this._addResult(object);
216
217         WI.showRepresentedObject(object);
218     }
219
220     export(object)
221     {
222         console.assert(object instanceof WI.AuditTestCase || object instanceof WI.AuditTestGroup || object instanceof WI.AuditTestCaseResult || object instanceof WI.AuditTestGroupResult, object);
223
224         let filename = object.name;
225         if (object instanceof WI.AuditTestResultBase)
226             filename = WI.UIString("%s Result").format(filename);
227
228         let url = "web-inspector:///" + encodeURI(filename) + ".json";
229
230         WI.FileUtilities.save({
231             url,
232             content: JSON.stringify(object),
233             forceSaveAs: true,
234         });
235     }
236
237     loadStoredTests()
238     {
239         if (this._tests.length)
240             return;
241
242         WI.objectStores.audits.getAll().then(async (tests) => {
243             for (let payload of tests) {
244                 let test = await WI.AuditTestGroup.fromPayload(payload) || await WI.AuditTestCase.fromPayload(payload);
245                 if (!test)
246                     continue;
247
248                 const key = null;
249                 WI.objectStores.audits.associateObject(test, key, payload);
250
251                 this._addTest(test);
252             }
253
254             this.addDefaultTestsIfNeeded();
255         });
256     }
257
258     removeTest(test)
259     {
260         this._tests.remove(test);
261
262         this.dispatchEventToListeners(WI.AuditManager.Event.TestRemoved, {test});
263
264         if (!test.__default)
265             WI.objectStores.audits.deleteObject(test);
266     }
267
268     // Private
269
270     _addTest(test)
271     {
272         this._tests.push(test);
273
274         this.dispatchEventToListeners(WI.AuditManager.Event.TestAdded, {test});
275     }
276
277     _addResult(result)
278     {
279         if (!result || (Array.isArray(result) && !result.length))
280             return;
281
282         this._results.push(result);
283
284         this.dispatchEventToListeners(WI.AuditManager.Event.TestCompleted, {
285             result,
286             index: this._results.length - 1,
287         });
288     }
289
290     _handleFrameMainResourceDidChange(event)
291     {
292         if (!event.target.isMainFrame())
293             return;
294
295         if (this._runningState === WI.AuditManager.RunningState.Active)
296             this.stop();
297         else {
298             for (let test of this._tests)
299                 test.clearResult();
300         }
301     }
302
303     addDefaultTestsIfNeeded()
304     {
305         if (this._tests.length)
306             return;
307
308         const defaultTests = [
309             new WI.AuditTestGroup(WI.UIString("Demo Audit"), [
310                 new WI.AuditTestGroup(WI.UIString("Result Levels"), [
311                     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.")}),
312                     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.")}),
313                     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.")}),
314                     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.")}),
315                     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.")}),
316                 ], {description: WI.UIString("These are all of the different test result levels.")}),
317                 new WI.AuditTestGroup(WI.UIString("Result Data"), [
318                     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.")}),
319                     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.")}),
320                     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.")}),
321                 ], {description: WI.UIString("These are all of the different types of data that can be returned with the test result.")}),
322             ], {description: WI.UIString("These tests serve as a demonstration of the functionality and structure of audits.")}),
323             new WI.AuditTestGroup(WI.UIString("Accessibility"), [
324                 new WI.AuditTestGroup(WI.UIString("Attributes"), [
325                     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.")}),
326                     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.")}),
327                     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.")}),
328                     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.")}),
329                     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>.")}),
330                     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.")}),
331                 ], {description: WI.UIString("Tests for element attribute accessibility issues.")}),
332                 new WI.AuditTestGroup(WI.UIString("Elements"), [
333                     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.")}),
334                     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.")}),
335                     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>.")}),
336                 ], {description: WI.UIString("Tests for element accessibility issues.")}),
337                 new WI.AuditTestGroup(WI.UIString("Forms"), [
338                     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>.")}),
339                     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>.")}),
340                     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.")}),
341                 ], {description: WI.UIString("Tests the accessibility of form elements.")}),
342             ], {description: WI.UIString("Tests for ways to improve accessibility.")}),
343         ];
344
345         let checkDisabledDefaultTest = (test) => {
346             if (this._disabledDefaultTestsSetting.value.includes(test.name))
347                 test.disabled = true;
348
349             if (test instanceof WI.AuditTestGroup) {
350                 for (let child of test.tests)
351                     checkDisabledDefaultTest(child);
352             }
353         };
354
355         for (let test of defaultTests) {
356             checkDisabledDefaultTest(test);
357
358             test.__default = true;
359             this._addTest(test);
360         }
361     }
362 };
363
364 WI.AuditManager.RunningState = {
365     Disabled: "disabled",
366     Inactive: "inactive",
367     Active: "active",
368     Stopping: "stopping",
369 };
370
371 WI.AuditManager.Event = {
372     EditingChanged: "audit-manager-editing-changed",
373     TestAdded: "audit-manager-test-added",
374     TestCompleted: "audit-manager-test-completed",
375     TestRemoved: "audit-manager-test-removed",
376     TestScheduled: "audit-manager-test-scheduled",
377 };