testaro 78.4.0 → 78.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tests/qualWeb.js CHANGED
@@ -13,55 +13,26 @@ Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.reporter = void 0;
14
14
  const xPath_1 = require("../procs/xPath");
15
15
  const standard_1 = require("../procs/standard");
16
+ const fs_1 = require("fs");
17
+ // CONSTANTS
16
18
  /*
17
- The @qualweb packages declare their types only in package-exports maps, which
18
- this project's node10 module resolution cannot read, so the imports stay
19
- requires and are untyped.
19
+ QualWeb runs in the page under test, as @qualweb/core runs it in its own browser: the
20
+ page-side bundles first, then one bundle per section. It used to be given page.content() and
21
+ run by @qualweb/core, which loads that HTML with setContent() into a blank page — so relative
22
+ stylesheet links resolved nowhere, the page it tested was unstyled, and every CSS-dependent
23
+ rule (contrast, visibility, text spacing…) judged a page the user never sees. The bundles are
24
+ evaluated (not added as script tags), so the page's Content Security Policy does not apply.
20
25
  */
21
- const { QualWeb } = require('@qualweb/core');
22
- const { ACTRules } = require('@qualweb/act-rules');
23
- const { WCAGTechniques } = require('@qualweb/wcag-techniques');
24
- const { BestPractices } = require('@qualweb/best-practices');
25
- const { PlaywrightDriver } = require('@qualweb/playwright-driver');
26
- const config_1 = require("../procs/config");
27
- const qualWebInstances = {};
28
- const getQualWeb = (options) => {
29
- const key = `${options.stealth}:${options.adBlock}`;
30
- if (!qualWebInstances[key]) {
31
- let launcher;
32
- if (options.stealth) {
33
- const { chromium } = require('playwright-extra');
34
- chromium.use(require('puppeteer-extra-plugin-stealth')());
35
- launcher = chromium;
36
- }
37
- const qualWeb = new QualWeb(undefined, new PlaywrightDriver(launcher ? { launcher } : {}));
38
- if (options.adBlock) {
39
- const { PlaywrightBlocker } = require('@ghostery/adblocker-playwright');
40
- let blockerPromise = null;
41
- qualWeb.use({
42
- async beforePageLoad(page) {
43
- blockerPromise ??= PlaywrightBlocker.fromPrebuiltAdsAndTracking(fetch, {
44
- path: require('path').join(require('os').tmpdir(), 'qualweb-adblocker-engine.bin'),
45
- read: require('fs').promises.readFile,
46
- write: require('fs').promises.writeFile
47
- }).catch((error) => {
48
- console.log(`qualWeb: ad blocker unavailable, continuing without it (${error.message})`);
49
- return null;
50
- });
51
- const blocker = await blockerPromise;
52
- if (blocker) {
53
- await blocker.enableBlockingInPage(page.nativePage);
54
- }
55
- }
56
- });
57
- }
58
- qualWebInstances[key] = qualWeb;
59
- }
60
- return qualWebInstances[key];
26
+ const readBundle = (name) => (0, fs_1.readFileSync)(require.resolve(name), 'utf8');
27
+ let pageBundles = null;
28
+ const sectionBundles = {};
29
+ const sectionPackages = {
30
+ 'act-rules': '@qualweb/act-rules',
31
+ 'wcag-techniques': '@qualweb/wcag-techniques',
32
+ 'best-practices': '@qualweb/best-practices'
61
33
  };
62
- const actRulesModule = new ACTRules({});
63
- const wcagModule = new WCAGTechniques({});
64
- const bpModule = new BestPractices({});
34
+ // Rules a section may exclude after they crash before the section is given up on.
35
+ const MAX_EXCLUDED = 5;
65
36
  // Mapping of QualWeb module violation types to ordinal severities.
66
37
  const ordinalSeverities = {
67
38
  'act-rules': {
@@ -85,18 +56,8 @@ const ordinalSeverities = {
85
56
  // FUNCTIONS
86
57
  // Conducts and reports the QualWeb tests.
87
58
  const reporter = async (page, report, actIndex, timeLimit) => {
88
- const qualWebAct = report.acts[actIndex];
89
- const qualWeb = getQualWeb({
90
- stealth: qualWebAct.stealth ?? config_1.qualWebDefaults.stealth,
91
- adBlock: qualWebAct.adBlock ?? config_1.qualWebDefaults.adBlock
92
- });
93
59
  const act = report.acts[actIndex];
94
60
  const { rules } = act;
95
- const clusterOptions = {
96
- maxConcurrency: 1,
97
- timeout: timeLimit * 1000,
98
- monitor: false
99
- };
100
61
  // Initialize the act report.
101
62
  const data = {};
102
63
  const result = {
@@ -109,210 +70,208 @@ const reporter = async (page, report, actIndex, timeLimit) => {
109
70
  // Initialize the standard result.
110
71
  result.standardResult = (0, standard_1.getStandardResult)();
111
72
  }
112
- try {
113
- // Start the QualWeb core engine, which launches a Playwright browser.
114
- await qualWeb.start(clusterOptions);
115
- }
116
- // If the start fails:
117
- catch (error) {
73
+ /*
74
+ QualWeb tests HTML documents only: on anything else (e.g. an SVG image) its rules report
75
+ false results such as a missing lang on the html element.
76
+ */
77
+ const rootNamespace = await page.evaluate(() => document.documentElement?.namespaceURI ?? null)
78
+ .catch(() => null);
79
+ // If the document is not HTML:
80
+ if (rootNamespace !== 'http://www.w3.org/1999/xhtml') {
81
+ // Report this.
118
82
  return {
119
83
  data: {
120
84
  prevented: true,
121
- error: `Core engine start failed (${error.message})`
85
+ error: `qualWeb tests HTML documents only (root namespace ${rootNamespace})`
122
86
  },
123
87
  result
124
88
  };
125
89
  }
126
- // Otherwise, i.e. if the start succeeds, specify the invariant test options.
127
- const qualWebOptions = {
128
- log: {
129
- console: false,
130
- file: false
131
- },
132
- crawlOptions: {
133
- maxDepth: 0,
134
- maxUrls: 1,
135
- timeout: timeLimit * 1000,
136
- maxParallelCrawls: 1,
137
- logging: true
138
- },
139
- execute: {
140
- counter: true
141
- },
142
- modules: []
90
+ // Specify, per section, whether and what to test (a rules spec of 'act:', 'wcag:' or 'best:'
91
+ // alone skips that section; with numbers, it tests only those rules).
92
+ const allAAA = {
93
+ levels: ['A', 'AA', 'AAA'],
94
+ principles: ['Perceivable', 'Operable', 'Understandable', 'Robust']
143
95
  };
144
- try {
145
- // Provide the page content, including the data-xpath attributes.
146
- qualWebOptions.html = await page.content();
147
- // Specify which rules to test for, adding a custom execute property for report processing.
148
- const actSpec = rules ? rules.find(typeRules => typeRules.startsWith('act:')) : null;
149
- const wcagSpec = rules ? rules.find(typeRules => typeRules.startsWith('wcag:')) : null;
150
- const bestSpec = rules ? rules.find(typeRules => typeRules.startsWith('best:')) : null;
151
- if (actSpec) {
152
- if (actSpec === 'act:') {
153
- qualWebOptions.execute.act = false;
154
- }
155
- else {
156
- const actRules = actSpec.slice(4).split(',').map(num => `QW-ACT-R${num}`);
157
- qualWebOptions['act-rules'] = { rules: actRules };
158
- qualWebOptions.modules.push(actRulesModule);
159
- qualWebOptions.execute.act = true;
160
- }
96
+ const sectionSpec = (prefix, codePrefix, all) => {
97
+ const spec = rules?.find(typeRules => typeRules.startsWith(prefix));
98
+ if (spec === undefined) {
99
+ return all;
161
100
  }
162
- else {
163
- qualWebOptions['act-rules'] = {
164
- levels: ['A', 'AA', 'AAA'],
165
- principles: ['Perceivable', 'Operable', 'Understandable', 'Robust']
166
- };
167
- qualWebOptions.modules.push(actRulesModule);
168
- qualWebOptions.execute.act = true;
101
+ if (spec === prefix) {
102
+ return null;
169
103
  }
170
- if (wcagSpec) {
171
- if (wcagSpec === 'wcag:') {
172
- qualWebOptions.execute.wcag = false;
173
- }
174
- else {
175
- const wcagTechniques = wcagSpec.slice(5).split(',').map(num => `QW-WCAG-T${num}`);
176
- qualWebOptions['wcag-techniques'] = { techniques: wcagTechniques };
177
- qualWebOptions.modules.push(wcagModule);
178
- qualWebOptions.execute.wcag = true;
179
- }
104
+ return { include: spec.slice(prefix.length).split(',').map(num => `${codePrefix}${num}`) };
105
+ };
106
+ const sections = {};
107
+ for (const [section, options] of [
108
+ ['act-rules', sectionSpec('act:', 'QW-ACT-R', allAAA)],
109
+ ['wcag-techniques', sectionSpec('wcag:', 'QW-WCAG-T', allAAA)],
110
+ ['best-practices', sectionSpec('best:', 'QW-BP', {})]
111
+ ]) {
112
+ if (options) {
113
+ sections[section] = options;
180
114
  }
181
- else {
182
- qualWebOptions['wcag-techniques'] = {
183
- levels: ['A', 'AA', 'AAA'],
184
- principles: ['Perceivable', 'Operable', 'Understandable', 'Robust']
185
- };
186
- qualWebOptions.modules.push(wcagModule);
187
- qualWebOptions.execute.wcag = true;
115
+ }
116
+ const deadline = Date.now() + timeLimit * 1000;
117
+ try {
118
+ // Load QualWeb into the page.
119
+ pageBundles ??= ['@qualweb/qw-page', '@qualweb/util', '@qualweb/locale'].map(readBundle);
120
+ for (const source of pageBundles) {
121
+ await page.evaluate(source);
188
122
  }
189
- if (bestSpec) {
190
- if (bestSpec === 'best:') {
191
- qualWebOptions.execute.bp = false;
123
+ // The page's own source, which QualWeb's meta-element rules read.
124
+ const sourceHtml = await page.content();
125
+ const modules = {};
126
+ const rulePreventions = {};
127
+ // For each section to be tested:
128
+ for (const [section, options] of Object.entries(sections)) {
129
+ if (Date.now() > deadline) {
130
+ throw new Error(`time limit of ${timeLimit}s reached before ${section}`);
131
+ }
132
+ sectionBundles[section] ??= readBundle(sectionPackages[section]);
133
+ await page.evaluate(sectionBundles[section]);
134
+ /*
135
+ One rule that throws takes its whole section down with it (e.g. QW-ACT-R76 in 0.8.5 on
136
+ lab(), oklab(), color() or color-mix() colours, qualweb/qualweb#352), so a rule named in
137
+ the stack trace of a crash is excluded and the section rerun; a crash that names no rule
138
+ prevents only its section.
139
+ */
140
+ const outcome = await page.evaluate(({ section, options, sourceHtml, maxExcluded }) => {
141
+ // Only this section's bundle has been loaded, so name only its runner.
142
+ const Runner = section === 'act-rules'
143
+ // @ts-expect-error: defined by the section bundle evaluated above.
144
+ ? ACTRulesRunner
145
+ : section === 'wcag-techniques'
146
+ // @ts-expect-error: defined by the section bundle evaluated above.
147
+ ? WCAGTechniquesRunner
148
+ // @ts-expect-error: defined by the section bundle evaluated above.
149
+ : BestPracticesRunner;
150
+ const preventions = {};
151
+ const exclude = [];
152
+ for (;;) {
153
+ try {
154
+ const opts = exclude.length ? { ...options, exclude: [...(options.exclude ?? []), ...exclude] } : options;
155
+ const runner = new Runner(opts, 'en').configure(opts).test({ sourceHtml, newTabWasOpen: false });
156
+ window.__testaroQwRunners = { ...window.__testaroQwRunners, [section]: runner };
157
+ // QualWeb core keeps the act-rules runner here for its special-case pass.
158
+ if (section === 'act-rules') {
159
+ window.act = runner;
160
+ }
161
+ return { ok: true, preventions };
162
+ }
163
+ catch (error) {
164
+ const message = String(error?.message ?? error).slice(0, 200);
165
+ // Rule classes are named QW_ACT_R76, QW_WCAG_T23, QW_BP1.
166
+ const rule = String(error?.stack ?? '').match(/QW_(?:ACT_R|WCAG_T|BP)\d+/)?.[0].replace(/_/g, '-');
167
+ if (!rule || exclude.includes(rule) || exclude.length >= maxExcluded) {
168
+ preventions[section] = `section failed: ${message}`;
169
+ return { ok: false, preventions };
170
+ }
171
+ preventions[rule] = message;
172
+ exclude.push(rule);
173
+ }
174
+ }
175
+ }, { section, options, sourceHtml, maxExcluded: MAX_EXCLUDED });
176
+ Object.assign(rulePreventions, outcome.preventions);
177
+ if (!outcome.ok) {
178
+ continue;
192
179
  }
193
- else {
194
- const bestPractices = bestSpec.slice(5).split(',').map(num => `QW-BP${num}`);
195
- qualWebOptions['best-practices'] = { bestPractices };
196
- qualWebOptions.modules.push(bpModule);
197
- qualWebOptions.execute.bp = true;
180
+ /*
181
+ QualWeb core's special case for act-rules: QW-ACT-R40 (zoomed text not clipped) is
182
+ re-tested at a 640 × 512 viewport, then the viewport is restored.
183
+ */
184
+ const include = options.include;
185
+ if (section === 'act-rules' && (!include || include.includes('QW-ACT-R40'))) {
186
+ const viewport = page.viewportSize();
187
+ if (viewport) {
188
+ try {
189
+ await page.setViewportSize({ width: 640, height: 512 });
190
+ await page.evaluate(() => window.act?.testSpecial?.());
191
+ }
192
+ catch (error) {
193
+ rulePreventions['QW-ACT-R40'] = `special-case pass failed: ${String(error.message).slice(0, 200)}`;
194
+ }
195
+ finally {
196
+ await page.setViewportSize(viewport);
197
+ }
198
+ }
198
199
  }
200
+ modules[section] = await page.evaluate(section => JSON.parse(JSON.stringify(window.__testaroQwRunners[section].getReport())), section);
199
201
  }
200
- else {
201
- qualWebOptions['best-practices'] = {};
202
- qualWebOptions.modules.push(bpModule);
203
- qualWebOptions.execute.bp = true;
202
+ if (Object.keys(rulePreventions).length) {
203
+ data.rulePreventions = rulePreventions;
204
204
  }
205
- let qwReport;
206
- try {
207
- // Get the report.
208
- qwReport = await qualWeb.evaluate(qualWebOptions);
209
- }
210
- catch (error) {
211
- return {
212
- data: {
213
- prevented: true,
214
- error: `qualWeb evaluation failed (${error.message})`
215
- },
216
- result
217
- };
218
- }
219
- // Add the report to the result.
220
- result.nativeResult = qwReport.customHtml;
221
- const { nativeResult, standardResult } = result;
222
- // If the report contains, as it should, a copy of the DOM:
223
- if (nativeResult?.system?.page?.dom) {
224
- // Delete the copy for parsimony.
225
- delete nativeResult.system.page.dom;
226
- const { modules } = nativeResult;
227
- // If the report contains, as it should, a modules property:
228
- if (modules) {
229
- // For each test section in it:
230
- for (const section of ['act-rules', 'wcag-techniques', 'best-practices']) {
231
- // If testing in the section was specified:
232
- if (qualWebOptions[section]) {
233
- // If the section exists:
234
- if (modules[section]) {
235
- const { assertions } = modules[section];
236
- // If it contains assertions (test results):
237
- if (assertions) {
238
- const ruleIDs = Object.keys(assertions);
239
- // For each rule:
240
- for (const ruleID of ruleIDs) {
241
- const ruleAssertions = assertions[ruleID];
242
- const { metadata } = ruleAssertions;
243
- // If there were any warnings or failures:
244
- if (metadata?.warning || metadata?.failed) {
245
- // Delete nonviolations from the results.
246
- ruleAssertions.results = ruleAssertions.results.filter(raResult => raResult.verdict !== 'passed');
247
- // For each test result:
248
- for (const raResult of ruleAssertions.results) {
249
- const { elements, verdict } = raResult;
250
- // If any violations are reported:
251
- if (elements?.length) {
252
- // For each violating element:
253
- for (const element of elements) {
254
- // Limit the size of its reported excerpt.
255
- if (element.htmlCode?.length > 2000) {
256
- element.htmlCode = `${element.htmlCode.slice(0, 2000)} …`;
257
- }
258
- // If standard results are to be reported:
259
- if (standard) {
260
- const what = `[${verdict}] ${raResult.description}`;
261
- const xPath = (0, xPath_1.getAttributeXPath)(element.htmlCode);
262
- // Add an instance to the standard result.
263
- (0, standard_1.addInstance)(standardResult, {
264
- ruleID,
265
- what,
266
- ordinalSeverity: ordinalSeverities[section][verdict],
267
- outcome: verdict === 'warning' ? 'cantTell' : 'failed',
268
- catalogIndex: (0, xPath_1.getXPathCatalogIndex)(report, xPath)
269
- });
270
- }
271
- }
272
- ;
273
- }
274
- }
275
- ;
205
+ // Add the module reports to the result.
206
+ result.nativeResult = { system: { url: page.url(), evaluation: 'in-page' }, modules };
207
+ const { standardResult } = result;
208
+ // For each section that was to be tested:
209
+ for (const section of Object.keys(sections)) {
210
+ const moduleReport = modules[section];
211
+ /*
212
+ A section that failed as a whole is recorded in rulePreventions; the act is prevented
213
+ only if no section ran.
214
+ */
215
+ if (!moduleReport) {
216
+ continue;
217
+ }
218
+ const { assertions } = moduleReport;
219
+ // If it contains assertions (test results):
220
+ if (assertions) {
221
+ const ruleIDs = Object.keys(assertions);
222
+ // For each rule:
223
+ for (const ruleID of ruleIDs) {
224
+ const ruleAssertions = assertions[ruleID];
225
+ const { metadata } = ruleAssertions;
226
+ // If there were any warnings or failures:
227
+ if (metadata?.warning || metadata?.failed) {
228
+ // Delete nonviolations from the results.
229
+ ruleAssertions.results = ruleAssertions.results.filter(raResult => raResult.verdict !== 'passed');
230
+ // For each test result:
231
+ for (const raResult of ruleAssertions.results) {
232
+ const { elements, verdict } = raResult;
233
+ // If any violations are reported:
234
+ if (elements?.length) {
235
+ // For each violating element:
236
+ for (const element of elements) {
237
+ // Limit the size of its reported excerpt.
238
+ if (element.htmlCode?.length > 2000) {
239
+ element.htmlCode = `${element.htmlCode.slice(0, 2000)} …`;
276
240
  }
277
- // Otherwise, i.e. if there were no warnings or failures:
278
- else {
279
- // Delete the rule.
280
- delete assertions[ruleID];
241
+ // If standard results are to be reported:
242
+ if (standard) {
243
+ const what = `[${verdict}] ${raResult.description}`;
244
+ const xPath = (0, xPath_1.getAttributeXPath)(element.htmlCode);
245
+ // Add an instance to the standard result.
246
+ (0, standard_1.addInstance)(standardResult, {
247
+ ruleID,
248
+ what,
249
+ ordinalSeverity: ordinalSeverities[section][verdict],
250
+ outcome: verdict === 'warning' ? 'cantTell' : 'failed',
251
+ catalogIndex: (0, xPath_1.getXPathCatalogIndex)(report, xPath)
252
+ });
281
253
  }
282
254
  }
283
255
  ;
284
256
  }
285
- // Otherwise, i.e. if it contains no assertions:
286
- else {
287
- // Report this.
288
- data.prevented = true;
289
- data.error = 'No assertions';
290
- }
291
- }
292
- // Otherwise, i.e. if the section is missing:
293
- else {
294
- // Report this.
295
- data.prevented = true;
296
- data.error = `No ${section} section`;
297
257
  }
258
+ ;
259
+ }
260
+ // Otherwise, i.e. if there were no warnings or failures:
261
+ else {
262
+ // Delete the rule.
263
+ delete assertions[ruleID];
298
264
  }
299
265
  }
300
- }
301
- // Otherwise, i.e. if the report does not contain a modules property:
302
- else {
303
- // Report this.
304
- data.prevented = true;
305
- data.error = 'No modules';
266
+ ;
306
267
  }
307
268
  }
308
- // Otherwise, i.e. if the report does not contain a copy of the DOM:
309
- else {
269
+ // If no section ran:
270
+ if (!Object.keys(modules).length) {
310
271
  // Report this.
311
272
  data.prevented = true;
312
- data.error = 'No DOM';
273
+ data.error = `No qualWeb section ran (${JSON.stringify(rulePreventions).slice(0, 300)})`;
313
274
  }
314
- // Stop the QualWeb core engine.
315
- await qualWeb.stop();
316
275
  // Test whether the result is an object.
317
276
  try {
318
277
  JSON.stringify(result);