7684b4d9601f445b1397f326b824a15a24b42ef0
[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         WI.Frame.addEventListener(WI.Frame.Event.MainResourceDidChange, this._handleFrameMainResourceDidChange, this);
39     }
40
41     static synthesizeError(message)
42     {
43         let consoleMessage = new WI.ConsoleMessage(WI.mainTarget, WI.ConsoleMessage.MessageSource.Other, WI.ConsoleMessage.MessageLevel.Error, WI.UIString("Audit error: %s").format(message));
44         consoleMessage.shouldRevealConsole = true;
45
46         WI.consoleLogViewController.appendConsoleMessage(consoleMessage);
47     }
48
49     // Public
50
51     get tests() { return this._tests; }
52     get results() { return this._results; }
53     get runningState() { return this._runningState; }
54
55     async start(tests)
56     {
57         console.assert(this._runningState === WI.AuditManager.RunningState.Inactive);
58         if (this._runningState !== WI.AuditManager.RunningState.Inactive)
59             return;
60
61         if (tests && tests.length)
62             tests = tests.filter((test) => typeof test === "object" && test instanceof WI.AuditTestBase);
63         else
64             tests = this._tests;
65
66         if (!tests.length)
67             return;
68
69         let mainResource = WI.networkManager.mainFrame.mainResource;
70
71         this._runningState = WI.AuditManager.RunningState.Active;
72         this._runningTests = tests;
73         for (let test of this._runningTests)
74             test.clearResult();
75
76         this.dispatchEventToListeners(WI.AuditManager.Event.TestScheduled);
77
78         await Promise.chain(this._runningTests.map((test) => () => this._runningState === WI.AuditManager.RunningState.Active ? test.start() : null));
79
80         let result = this._runningTests.map((test) => test.result).filter((result) => !!result);
81
82         this._runningState = WI.AuditManager.RunningState.Inactive;
83         this._runningTests = [];
84
85         this._addResult(result);
86
87         if (mainResource !== WI.networkManager.mainFrame.mainResource) {
88             // Navigated while tests were running.
89             for (let test of this._tests)
90                 test.clearResult();
91         }
92     }
93
94     stop()
95     {
96         console.assert(this._runningState === WI.AuditManager.RunningState.Active);
97         if (this._runningState !== WI.AuditManager.RunningState.Active)
98             return;
99
100         for (let test of this._runningTests)
101             test.stop();
102
103         this._runningState = WI.AuditManager.RunningState.Stopping;
104     }
105
106     async processJSON({json, error})
107     {
108         if (error) {
109             WI.AuditManager.synthesizeError(error);
110             return;
111         }
112
113         let object = await WI.AuditTestGroup.fromPayload(json) || await WI.AuditTestCase.fromPayload(json);
114         if (!object) {
115             object = await WI.AuditTestGroupResult.fromPayload(json) || await WI.AuditTestCaseResult.fromPayload(json);
116             if (!object) {
117                 WI.AuditManager.synthesizeError(WI.UIString("invalid JSON."));
118                 return;
119             }
120         }
121
122         if (object instanceof WI.AuditTestBase) {
123             this._addTest(object);
124             WI.objectStores.audits.addObject(object);
125         } else if (object instanceof WI.AuditTestResultBase)
126             this._addResult(object);
127
128         WI.showRepresentedObject(object);
129     }
130
131     export(object)
132     {
133         console.assert(object instanceof WI.AuditTestCase || object instanceof WI.AuditTestGroup || object instanceof WI.AuditTestCaseResult || object instanceof WI.AuditTestGroupResult, object);
134
135         let filename = object.name;
136         if (object instanceof WI.AuditTestResultBase)
137             filename = WI.UIString("%s Result").format(filename);
138
139         let url = "web-inspector:///" + encodeURI(filename) + ".json";
140
141         WI.FileUtilities.save({
142             url,
143             content: JSON.stringify(object),
144             forceSaveAs: true,
145         });
146     }
147
148     loadStoredTests()
149     {
150         if (this._tests.length)
151             return;
152
153         WI.objectStores.audits.getAll().then(async (tests) => {
154             for (let payload of tests) {
155                 let test = await WI.AuditTestGroup.fromPayload(payload) || await WI.AuditTestCase.fromPayload(payload);
156                 if (!test)
157                     continue;
158
159                 const key = null;
160                 WI.objectStores.audits.associateObject(test, key, payload);
161
162                 this._addTest(test);
163             }
164
165             this.addDefaultTestsIfNeeded();
166         });
167     }
168
169     removeTest(test)
170     {
171         this._tests.remove(test);
172
173         this.dispatchEventToListeners(WI.AuditManager.Event.TestRemoved, {test});
174
175         if (!test.__default)
176             WI.objectStores.audits.deleteObject(test);
177     }
178
179     // Private
180
181     _addTest(test)
182     {
183         this._tests.push(test);
184
185         this.dispatchEventToListeners(WI.AuditManager.Event.TestAdded, {test});
186     }
187
188     _addResult(result)
189     {
190         if (!result || (Array.isArray(result) && !result.length))
191             return;
192
193         this._results.push(result);
194
195         this.dispatchEventToListeners(WI.AuditManager.Event.TestCompleted, {
196             result,
197             index: this._results.length - 1,
198         });
199     }
200
201     _handleFrameMainResourceDidChange(event)
202     {
203         if (!event.target.isMainFrame())
204             return;
205
206         if (this._runningState === WI.AuditManager.RunningState.Active)
207             this.stop();
208         else {
209             for (let test of this._tests)
210                 test.clearResult();
211         }
212     }
213
214     addDefaultTestsIfNeeded()
215     {
216         if (this._tests.length)
217             return;
218
219         const defaultTests = [
220             new WI.AuditTestGroup(WI.UIString("Demo Audit"), [
221                 new WI.AuditTestGroup(WI.UIString("Result Levels"), [
222                     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.")}),
223                     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.")}),
224                     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.")}),
225                     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.")}),
226                     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.")}),
227                 ], {description: WI.UIString("These are all of the different test result levels.")}),
228                 new WI.AuditTestGroup(WI.UIString("Result Data"), [
229                     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.")}),
230                     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.")}),
231                     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.")}),
232                 ], {description: WI.UIString("These are all of the different types of data that can be returned with the test result.")}),
233             ], {description: WI.UIString("These tests serve as a demonstration of the functionality and structure of audits.")}),
234             new WI.AuditTestGroup(WI.UIString("Accessibility"), [
235                 new WI.AuditTestGroup(WI.UIString("Attributes"), [
236                     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.")}),
237                     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.")}),
238                     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.")}),
239                     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.")}),
240                     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>.")}),
241                     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.")}),
242                 ], {description: WI.UIString("Tests for element attribute accessibility issues.")}),
243                 new WI.AuditTestGroup(WI.UIString("Elements"), [
244                     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.")}),
245                     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.")}),
246                     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>.")}),
247                 ], {description: WI.UIString("Tests for element accessibility issues.")}),
248                 new WI.AuditTestGroup(WI.UIString("Forms"), [
249                     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>.")}),
250                     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>.")}),
251                     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.")}),
252                 ], {description: WI.UIString("Tests the accessibility of form elements.")}),
253             ], {description: WI.UIString("Tests for ways to improve accessibility.")}),
254         ];
255
256         for (let test of defaultTests) {
257             test.__default = true;
258             this._addTest(test);
259         }
260     }
261 };
262
263 WI.AuditManager.RunningState = {
264     Inactive: "inactive",
265     Active: "active",
266     Stopping: "stopping",
267 };
268
269 WI.AuditManager.Event = {
270     TestAdded: "audit-manager-test-added",
271     TestCompleted: "audit-manager-test-completed",
272     TestRemoved: "audit-manager-test-removed",
273     TestScheduled: "audit-manager-test-scheduled",
274 };