Web Inspector: Audit: add default tests
[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 test 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         this._runningState = WI.AuditManager.RunningState.Active;
70         this._runningTests = tests;
71         for (let test of this._runningTests)
72             test.clearResult();
73
74         this.dispatchEventToListeners(WI.AuditManager.Event.TestScheduled);
75
76         await Promise.chain(this._runningTests.map((test) => () => this._runningState === WI.AuditManager.RunningState.Active ? test.start() : null));
77
78         let result = this._runningTests.map((test) => test.result).filter((result) => !!result);
79
80         this._runningState = WI.AuditManager.RunningState.Inactive;
81         this._runningTests = [];
82
83         this._addResult(result);
84     }
85
86     stop()
87     {
88         console.assert(this._runningState === WI.AuditManager.RunningState.Active);
89         if (this._runningState !== WI.AuditManager.RunningState.Active)
90             return;
91
92         for (let test of this._runningTests)
93             test.stop();
94
95         this._runningState = WI.AuditManager.RunningState.Stopping;
96     }
97
98     async processJSON({json, error})
99     {
100         if (error) {
101             WI.AuditManager.synthesizeError(error);
102             return;
103         }
104
105         let object = await WI.AuditTestGroup.fromPayload(json) || await WI.AuditTestCase.fromPayload(json);
106         if (!object) {
107             object = await WI.AuditTestGroupResult.fromPayload(json) || await WI.AuditTestCaseResult.fromPayload(json);
108             if (!object) {
109                 WI.AuditManager.synthesizeError(WI.UIString("invalid JSON."));
110                 return;
111             }
112         }
113
114         if (object instanceof WI.AuditTestBase) {
115             this._addTest(object);
116             WI.objectStores.audits.addObject(object);
117         } else if (object instanceof WI.AuditTestResultBase)
118             this._addResult(object);
119
120         WI.showRepresentedObject(object);
121     }
122
123     export(object)
124     {
125         console.assert(object instanceof WI.AuditTestCase || object instanceof WI.AuditTestGroup || object instanceof WI.AuditTestCaseResult || object instanceof WI.AuditTestGroupResult, object);
126
127         let filename = object.name;
128         if (object instanceof WI.AuditTestResultBase)
129             filename = WI.UIString("%s Result").format(filename);
130
131         let url = "web-inspector:///" + encodeURI(filename) + ".json";
132
133         WI.FileUtilities.save({
134             url,
135             content: JSON.stringify(object),
136             forceSaveAs: true,
137         });
138     }
139
140     loadStoredTests()
141     {
142         WI.objectStores.audits.getAll().then(async (tests) => {
143             for (let payload of tests) {
144                 let test = await WI.AuditTestGroup.fromPayload(payload) || await WI.AuditTestCase.fromPayload(payload);
145                 if (!test)
146                     continue;
147
148                 const key = null;
149                 WI.objectStores.audits.associateObject(test, key, payload);
150
151                 this._addTest(test);
152             }
153
154             this.addDefaultTestsIfNeeded();
155         });
156     }
157
158     removeTest(test)
159     {
160         this._tests.remove(test);
161
162         this.dispatchEventToListeners(WI.AuditManager.Event.TestRemoved, {test});
163
164         WI.objectStores.audits.deleteObject(test);
165     }
166
167     // Private
168
169     _addTest(test)
170     {
171         this._tests.push(test);
172
173         this.dispatchEventToListeners(WI.AuditManager.Event.TestAdded, {test});
174     }
175
176     _addResult(result)
177     {
178         if (!result || (Array.isArray(result) && !result.length))
179             return;
180
181         this._results.push(result);
182
183         this.dispatchEventToListeners(WI.AuditManager.Event.TestCompleted, {
184             result,
185             index: this._results.length - 1,
186         });
187     }
188
189     _handleFrameMainResourceDidChange(event)
190     {
191         if (!event.target.isMainFrame())
192             return;
193
194         for (let test of this._tests)
195             test.clearResult();
196     }
197
198     addDefaultTestsIfNeeded()
199     {
200         if (this._tests.length)
201             return;
202
203         const defaultTests = [
204             new WI.AuditTestGroup(WI.UIString("Demo Audit"), [
205                 new WI.AuditTestGroup(WI.UIString("Result Levels"), [
206                     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.")}),
207                     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.")}),
208                     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.")}),
209                     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.")}),
210                     new WI.AuditTestCase(`level-unsupported`, `function() { return {level: "unsupported"}; }`, {description: WI.UIString("This is what the result of a unsupported test with no data looks like.")}),
211                 ], {description: WI.UIString("These are all of the different test result levels.")}),
212                 new WI.AuditTestGroup(WI.UIString("Result Data"), [
213                     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.")}),
214                     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.")}),
215                     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.")}),
216                 ], {description: WI.UIString("These are all of the different types of data that can be returned with the test result.")}),
217             ], {description: WI.UIString("These tests serve as a demonstration of the functionality and structure of audits.")}),
218             new WI.AuditTestGroup(WI.UIString("Accessibility"), [
219                 new WI.AuditTestGroup(WI.UIString("Attributes"), [
220                     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.")}),
221                     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 of image maps have alternate text.")}),
222                     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.")}),
223                     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 <area> elements of image maps have alternate text.")}),
224                     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 document body.")}),
225                     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.")}),
226                 ], {description: WI.UIString("Tests for element attribute accessibility issues.")}),
227                 new WI.AuditTestGroup(WI.UIString("Elements"), [
228                     new WI.AuditTestCase(`blink`, `function() { let domNodes = Array.from(document.getElementsByTagName("blink")); return { level: domNodes.length ? "warn" : "pass", domNodes }; }`, {description: WI.UIString("Ensure hidden=true is not present on the document body.")}),
229                     new WI.AuditTestCase(`marquee`, `function() { let domNodes = Array.from(document.getElementsByTagName("marquee")); return { level: domNodes.length ? "warn" : "pass", domNodes }; }`, {description: WI.UIString("Ensure hidden=true is not present on the document body.")}),
230                     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>.")}),
231                 ], {description: WI.UIString("Tests for element accessibility issues.")}),
232                 new WI.AuditTestGroup(WI.UIString("Forms"), [
233                     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.")}),
234                     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 legend is first child in form.")}),
235                     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 forms have at least one input.")}),
236                 ], {description: WI.UIString("Tests the accessibility of form elements.")}),
237             ], {description: WI.UIString("Tests for ways to improve accessibility.")}),
238         ];
239
240         for (let test of defaultTests) {
241             this._addTest(test);
242             WI.objectStores.audits.addObject(test);
243         }
244     }
245 };
246
247 WI.AuditManager.RunningState = {
248     Inactive: "inactive",
249     Active: "active",
250     Stopping: "stopping",
251 };
252
253 WI.AuditManager.Event = {
254     TestAdded: "audit-manager-test-added",
255     TestCompleted: "audit-manager-test-completed",
256     TestRemoved: "audit-manager-test-removed",
257     TestScheduled: "audit-manager-test-scheduled",
258 };