semantica11y 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Semantica11y Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,174 @@
1
+ <img src="./Semantically-logo.png" alt="Semantica11y logo" width="500" height="350">
2
+
3
+ # Semantica11y
4
+
5
+ A JavaScript analysis engine for checking webpages (HTML) for non-semantic HTML elements with ARIA using intelligent suggestions for semantic improvements.
6
+
7
+ ## 🎯 Purpose
8
+
9
+ Using semantic and native HTML elements is the foundation to building an accessible webpage. Semantica11y is here to help ensure that when you build your web applications, it is build semantically first, for better accessibility and long term code sustainability!
10
+
11
+ ## 📦 Installation
12
+
13
+ ```bash
14
+ npm install semantica11y
15
+ ```
16
+
17
+ ## 🚀 Quick Start
18
+
19
+ ```javascript
20
+ import { Analyzer } from 'semantica11y';
21
+
22
+ const analyzer = new Analyzer();
23
+
24
+ const html = `
25
+ <html>
26
+ <body>
27
+ <div role="banner">Header</div>
28
+ <img src="logo.png" />
29
+ <form>
30
+ <input type="text" id="name" />
31
+ </form>
32
+ </body>
33
+ </html>
34
+ `;
35
+
36
+ const results = await analyzer.analyzeHTML(html);
37
+ console.log(analyzer.formatResults(results));
38
+ ```
39
+
40
+ ## 📖 Usage
41
+
42
+ ### Basic Analysis
43
+
44
+ ```javascript
45
+ import { Analyzer } from 'semantica11y';
46
+
47
+ const analyzer = new Analyzer();
48
+
49
+ // Analyze HTML string
50
+ const results = await analyzer.analyzeHTML(htmlString, 'https://example.com');
51
+
52
+ // Get formatted output
53
+ console.log(analyzer.formatResults(results));
54
+ ```
55
+
56
+ ### Reports
57
+
58
+ ```javascript
59
+ import { Analyzer, exportTextReport, formatConsoleReport } from 'semantica11y';
60
+
61
+ const analyzer = new Analyzer();
62
+ const results = await analyzer.analyzeHTML(html);
63
+
64
+ console.log(formatConsoleReport(results));
65
+ await exportTextReport(results, './semantica11y-report.txt');
66
+ ```
67
+
68
+ ### Custom Rules
69
+
70
+ ```javascript
71
+ const customRules = [
72
+ {
73
+ id: 'custom-rule',
74
+ name: 'My Custom Rule',
75
+ enabled: true,
76
+ description: 'Custom accessibility check',
77
+ check(document) {
78
+ const issues = [];
79
+ // Your custom check logic
80
+ return issues;
81
+ }
82
+ }
83
+ ];
84
+
85
+ const analyzer = new Analyzer({ rules: customRules });
86
+ ```
87
+
88
+ ## 📋 Default Rules
89
+
90
+ Semantica11y ships with 11 default rules that check semantic HTML, ARIA usage, headings, landmarks, forms, images, disclosure controls, modal dialogs, and native label conflicts.
91
+
92
+ For the full rule-by-rule reference, see [src/engine/rules/README.md](./src/engine/rules/README.md).
93
+
94
+ ## 🧪 Testing
95
+
96
+ Run the test suite:
97
+
98
+ ```bash
99
+ npm test
100
+ ```
101
+
102
+ Run tests in watch mode:
103
+
104
+ ```bash
105
+ npm run test:watch
106
+ ```
107
+
108
+ Run the Playwright example against `https://example.com`:
109
+
110
+ ```bash
111
+ node examples/basic.js
112
+ ```
113
+
114
+ Analyze a different page:
115
+
116
+ ```bash
117
+ node examples/basic.js https://www.statefarm.com
118
+ ```
119
+
120
+ ## 📦 Build Package
121
+
122
+ Create a clean package directory:
123
+
124
+ ```bash
125
+ npm run build
126
+ ```
127
+
128
+ Create a tarball from the build output:
129
+
130
+ ```bash
131
+ npm pack ./dist
132
+ ```
133
+
134
+ ## 🏗️ Project Structure
135
+
136
+ ```
137
+ semantica11y/
138
+ ├── src/
139
+ │ ├── index.js # Main export
140
+ │ ├── analyzer.js # Core analyzer class
141
+ │ └── engine/
142
+ │ ├── index.js # RuleEngine class
143
+ │ ├── definitions.js # Default rule registry
144
+ │ ├── reporter/ # Report formatting and exporting
145
+ │ ├── rules/ # Individual rule definitions
146
+ │ ├── semantic-role-mappings.js
147
+ │ └── utils.js
148
+ ├── examples/
149
+ │ └── basic.js # Usage example
150
+ ├── test/
151
+ │ ├── analyzer.test.js # Analyzer tests
152
+ │ └── rules.test.js # RuleEngine tests
153
+ ├── package.json
154
+ └── README.md
155
+ ```
156
+
157
+ ## 🤝 Contributing
158
+
159
+ Contributions are welcome! Please feel free to submit a Pull Request.
160
+
161
+ ## 📄 License
162
+
163
+ MIT
164
+
165
+ ## 🔗 Resources
166
+
167
+ - [WCAG Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
168
+ - [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/)
169
+ - [Semantic HTML](https://developer.mozilla.org/en-US/docs/Glossary/Semantic_HTML)
170
+ - [Web Accessibility](https://www.w3.org/WAI/)
171
+
172
+ ## 📞 Support
173
+
174
+ For issues, questions, or suggestions, please create an issue in the repository.
Binary file
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "semantica11y",
3
+ "version": "1.0.0",
4
+ "description": "A JavaScript engine to check webpages for ARIA and non-semantic HTML elements with suggestions for improvements",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "files": [
8
+ "src",
9
+ "README.md",
10
+ "LICENSE",
11
+ "Semantically-logo.png"
12
+ ],
13
+ "exports": {
14
+ ".": "./src/index.js",
15
+ "./analyzer": "./src/analyzer.js",
16
+ "./rules": "./src/engine/index.js",
17
+ "./engine": "./src/engine/index.js",
18
+ "./reporter": "./src/engine/reporter/index.js"
19
+ },
20
+ "scripts": {
21
+ "build": "node scripts/build-package.js",
22
+ "pack:dist": "npm run build && npm pack ./dist",
23
+ "test": "node --test test/**/*.test.js",
24
+ "test:watch": "node --test test/**/*.test.js --watch",
25
+ "lint": "echo 'Add eslint configuration'",
26
+ "example": "node examples/basic.js"
27
+ },
28
+ "keywords": [
29
+ "accessibility",
30
+ "a11y",
31
+ "ARIA",
32
+ "semantic",
33
+ "HTML",
34
+ "web-standards",
35
+ "analyzer"
36
+ ],
37
+ "author": "",
38
+ "license": "MIT",
39
+ "dependencies": {
40
+ "jsdom": "^24.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "playwright": "^1.60.0"
44
+ }
45
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * HTML Analyzer - Core analysis engine for semantic and ARIA compliance
3
+ */
4
+
5
+ import { JSDOM } from 'jsdom';
6
+ import { RuleEngine } from './engine/index.js';
7
+ import { formatConsoleReport } from './engine/reporter/index.js';
8
+
9
+ export class Analyzer {
10
+ /**
11
+ * Creates a new Analyzer instance
12
+ * @param {Object} options - Configuration options
13
+ * @param {Array} options.rules - Custom rules to apply (uses defaults if not provided)
14
+ * @param {boolean} options.includeWarnings - Include warning-level issues (default: true)
15
+ */
16
+ constructor(options = {}) {
17
+ this.options = {
18
+ includeWarnings: true,
19
+ ...options,
20
+ };
21
+ this.ruleEngine = new RuleEngine(options.rules);
22
+ this.results = null;
23
+ }
24
+
25
+ /**
26
+ * Analyze HTML content for semantic and ARIA issues
27
+ * @param {string} html - HTML content to analyze
28
+ * @param {string} url - Optional URL for context
29
+ * @returns {Promise<Object>} Analysis results with issues and suggestions
30
+ */
31
+ async analyzeHTML(html, url = '') {
32
+ try {
33
+ const dom = new JSDOM(html, url ? { url } : undefined);
34
+ const document = dom.window.document;
35
+
36
+ this.results = {
37
+ url,
38
+ timestamp: new Date().toISOString(),
39
+ summary: {
40
+ total: 0,
41
+ errors: 0,
42
+ warnings: 0,
43
+ suggestions: 0,
44
+ },
45
+ issues: [],
46
+ };
47
+
48
+ // Analyze the document with all rules
49
+ await this.ruleEngine.analyze(document, this.results);
50
+
51
+ return this.results;
52
+ } catch (error) {
53
+ throw new Error(`Failed to analyze HTML: ${error.message}`);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Analyze HTML from a file
59
+ * @param {string} filePath - Path to HTML file
60
+ * @returns {Promise<Object>} Analysis results
61
+ */
62
+ async analyzeFile(filePath) {
63
+ const fs = await import('fs').then((m) => m.promises);
64
+ const html = await fs.readFile(filePath, 'utf-8');
65
+ return this.analyzeHTML(html, `file://${filePath}`);
66
+ }
67
+
68
+ /**
69
+ * Get results from the last analysis
70
+ * @returns {Object|null} Last analysis results or null if no analysis performed
71
+ */
72
+ getResults() {
73
+ return this.results;
74
+ }
75
+
76
+ /**
77
+ * Format results for display
78
+ * @param {Object} results - Analysis results
79
+ * @returns {string} Formatted output
80
+ */
81
+ formatResults(results = this.results) {
82
+ return formatConsoleReport(results);
83
+ }
84
+ }
@@ -0,0 +1,131 @@
1
+ function tagName(element) {
2
+ return element.tagName.toLowerCase();
3
+ }
4
+
5
+ export const ARIA_STRUCTURE_ROLE_MAPPINGS = [
6
+ {
7
+ role: 'article',
8
+ semanticElement: '<article>',
9
+ isSemanticMatch: (element) => tagName(element) === 'article',
10
+ },
11
+ {
12
+ role: 'blockquote',
13
+ semanticElement: '<blockquote>',
14
+ isSemanticMatch: (element) => tagName(element) === 'blockquote',
15
+ },
16
+ {
17
+ role: 'caption',
18
+ semanticElement: '<caption>',
19
+ isSemanticMatch: (element) => tagName(element) === 'caption',
20
+ },
21
+ {
22
+ role: 'cell',
23
+ semanticElement: '<td>',
24
+ isSemanticMatch: (element) => tagName(element) === 'td',
25
+ },
26
+ {
27
+ role: 'code',
28
+ semanticElement: '<code>',
29
+ isSemanticMatch: (element) => tagName(element) === 'code',
30
+ },
31
+ {
32
+ role: 'columnheader',
33
+ semanticElement: '<th scope="col">',
34
+ isSemanticMatch: (element) => tagName(element) === 'th',
35
+ },
36
+ {
37
+ role: 'definition',
38
+ semanticElement: '<dfn>',
39
+ isSemanticMatch: (element) => tagName(element) === 'dfn',
40
+ },
41
+ {
42
+ role: 'deletion',
43
+ semanticElement: '<del>',
44
+ isSemanticMatch: (element) => tagName(element) === 'del',
45
+ },
46
+ {
47
+ role: 'emphasis',
48
+ semanticElement: '<em>',
49
+ isSemanticMatch: (element) => tagName(element) === 'em',
50
+ },
51
+ {
52
+ role: 'figure',
53
+ semanticElement: '<figure>',
54
+ isSemanticMatch: (element) => tagName(element) === 'figure',
55
+ },
56
+ {
57
+ role: 'generic',
58
+ semanticElement: 'a more specific semantic element or remove role="generic"',
59
+ isSemanticMatch: () => false,
60
+ },
61
+ {
62
+ role: 'heading',
63
+ semanticElement: '<h1> through <h6>',
64
+ isSemanticMatch: (element) => /^h[1-6]$/i.test(element.tagName),
65
+ },
66
+ {
67
+ role: 'list',
68
+ semanticElement: '<ul> or <ol>',
69
+ isSemanticMatch: (element) => ['ul', 'ol'].includes(tagName(element)),
70
+ },
71
+ {
72
+ role: 'listitem',
73
+ semanticElement: '<li>',
74
+ isSemanticMatch: (element) => tagName(element) === 'li',
75
+ },
76
+ {
77
+ role: 'paragraph',
78
+ semanticElement: '<p>',
79
+ isSemanticMatch: (element) => tagName(element) === 'p',
80
+ },
81
+ {
82
+ role: 'row',
83
+ semanticElement: '<tr>',
84
+ isSemanticMatch: (element) => tagName(element) === 'tr',
85
+ },
86
+ {
87
+ role: 'rowgroup',
88
+ semanticElement: '<thead>, <tbody>, or <tfoot>',
89
+ isSemanticMatch: (element) => ['thead', 'tbody', 'tfoot'].includes(tagName(element)),
90
+ },
91
+ {
92
+ role: 'rowheader',
93
+ semanticElement: '<th scope="row">',
94
+ isSemanticMatch: (element) => tagName(element) === 'th',
95
+ },
96
+ {
97
+ role: 'separator',
98
+ semanticElement: '<hr>',
99
+ isSemanticMatch: (element) => tagName(element) === 'hr',
100
+ },
101
+ {
102
+ role: 'strong',
103
+ semanticElement: '<strong>',
104
+ isSemanticMatch: (element) => tagName(element) === 'strong',
105
+ },
106
+ {
107
+ role: 'subscript',
108
+ semanticElement: '<sub>',
109
+ isSemanticMatch: (element) => tagName(element) === 'sub',
110
+ },
111
+ {
112
+ role: 'superscript',
113
+ semanticElement: '<sup>',
114
+ isSemanticMatch: (element) => tagName(element) === 'sup',
115
+ },
116
+ {
117
+ role: 'table',
118
+ semanticElement: '<table>',
119
+ isSemanticMatch: (element) => tagName(element) === 'table',
120
+ },
121
+ {
122
+ role: 'term',
123
+ semanticElement: '<dt>',
124
+ isSemanticMatch: (element) => tagName(element) === 'dt',
125
+ },
126
+ {
127
+ role: 'time',
128
+ semanticElement: '<time>',
129
+ isSemanticMatch: (element) => tagName(element) === 'time',
130
+ },
131
+ ];
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Rule Definitions - Semantic and ARIA compliance rules
3
+ */
4
+
5
+ import { ariaActionsRule } from './rules/aria-actions.js';
6
+ import { ariaExpandedRule } from './rules/aria-expanded.js';
7
+ import { ariaModalRule } from './rules/aria-modal.js';
8
+ import { ariaStructureRule } from './rules/aria-structure.js';
9
+ import { headingHierarchyRule } from './rules/heading-hierarchy.js';
10
+ import { imageAltRule } from './rules/image-alt.js';
11
+ import { missingFormLabelsRule } from './rules/missing-form-labels.js';
12
+ import { missingKeyLandmarkRule } from './rules/missing-key-landmark.js';
13
+ import { missingRoleActionRule } from './rules/missing-role-action.js';
14
+ import { nativeLabelRule } from './rules/native-label.js';
15
+ import { ariaLandmarksRule } from './rules/aria-landmarks.js';
16
+
17
+ /**
18
+ * Default set of rules for semantic HTML and ARIA analysis
19
+ */
20
+ export const DEFAULT_RULES = [
21
+ ariaLandmarksRule,
22
+ missingFormLabelsRule,
23
+ headingHierarchyRule,
24
+ ariaActionsRule,
25
+ ariaExpandedRule,
26
+ ariaModalRule,
27
+ ariaStructureRule,
28
+ missingRoleActionRule,
29
+ imageAltRule,
30
+ nativeLabelRule,
31
+ missingKeyLandmarkRule,
32
+ ];
33
+
34
+ /**
35
+ * Placeholder for custom rules users might define
36
+ */
37
+ export const CUSTOM_RULES = [];
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Rule Engine - Core logic for semantic and ARIA analysis rules
3
+ */
4
+
5
+ import { DEFAULT_RULES } from './definitions.js';
6
+
7
+ export class RuleEngine {
8
+ /**
9
+ * Creates a new RuleEngine instance
10
+ * @param {Array} customRules - Optional custom rules to extend/override defaults
11
+ */
12
+ constructor(customRules = []) {
13
+ this.rules = [...DEFAULT_RULES, ...customRules];
14
+ }
15
+
16
+ /**
17
+ * Add a custom rule
18
+ * @param {Object} rule - Rule definition
19
+ */
20
+ addRule(rule) {
21
+ this.rules.push(rule);
22
+ }
23
+
24
+ /**
25
+ * Analyze a document against all rules
26
+ * @param {Document} document - DOM document to analyze
27
+ * @param {Object} results - Results object to populate
28
+ */
29
+ async analyze(document, results) {
30
+ for (const rule of this.rules) {
31
+ if (!rule.enabled) continue;
32
+
33
+ try {
34
+ const issues = rule.check(document);
35
+ this.addIssuesToResults(results, issues);
36
+ } catch (error) {
37
+ console.error(`Error running rule "${rule.id}":`, error);
38
+ }
39
+ }
40
+
41
+ // Update summary
42
+ results.summary.total = results.issues.length;
43
+ }
44
+
45
+ /**
46
+ * Add issues from a rule to results
47
+ * @param {Object} results - Results object
48
+ * @param {Array} issues - Issues array from rule
49
+ */
50
+ addIssuesToResults(results, issues) {
51
+ issues.forEach((issue) => {
52
+ results.issues.push(issue);
53
+
54
+ // Update summary counts
55
+ if (issue.severity === 'error') {
56
+ results.summary.errors++;
57
+ } else if (issue.severity === 'warning') {
58
+ results.summary.warnings++;
59
+ } else if (issue.severity === 'suggestion') {
60
+ results.summary.suggestions++;
61
+ }
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Get all active rules
67
+ * @returns {Array} Active rules
68
+ */
69
+ getActiveRules() {
70
+ return this.rules.filter((r) => r.enabled);
71
+ }
72
+
73
+ /**
74
+ * Get rule by ID
75
+ * @param {string} id - Rule ID
76
+ * @returns {Object|undefined} Rule definition or undefined
77
+ */
78
+ getRule(id) {
79
+ return this.rules.find((r) => r.id === id);
80
+ }
81
+ }
82
+
83
+ export { DEFAULT_RULES };
@@ -0,0 +1,127 @@
1
+ import { writeFile } from 'fs/promises';
2
+
3
+ const COLORS = {
4
+ reset: '\x1b[0m',
5
+ red: '\x1b[31m',
6
+ yellow: '\x1b[33m',
7
+ cyan: '\x1b[36m',
8
+ bold: '\x1b[1m',
9
+ };
10
+
11
+ const ISSUE_SECTIONS = [
12
+ {
13
+ severity: 'error',
14
+ title: 'Errors',
15
+ color: COLORS.red,
16
+ },
17
+ {
18
+ severity: 'warning',
19
+ title: 'Warnings',
20
+ color: COLORS.yellow,
21
+ },
22
+ {
23
+ severity: 'suggestion',
24
+ title: 'Suggestions',
25
+ color: COLORS.cyan,
26
+ },
27
+ ];
28
+
29
+ function colorize(text, color, useColors) {
30
+ return useColors ? `${color}${text}${COLORS.reset}` : text;
31
+ }
32
+
33
+ function groupIssues(issues) {
34
+ const groups = new Map();
35
+
36
+ issues.forEach((issue) => {
37
+ const key = JSON.stringify({
38
+ rule: issue.rule,
39
+ message: issue.message,
40
+ suggestion: issue.suggestion || '',
41
+ });
42
+
43
+ if (!groups.has(key)) {
44
+ groups.set(key, {
45
+ rule: issue.rule,
46
+ message: issue.message,
47
+ suggestion: issue.suggestion,
48
+ elements: [],
49
+ });
50
+ }
51
+
52
+ groups.get(key).elements.push(issue.element);
53
+ });
54
+
55
+ return Array.from(groups.values());
56
+ }
57
+
58
+ function formatIssueGroup(group, index) {
59
+ const instanceLabel = group.elements.length === 1 ? 'instance' : 'instances';
60
+ let output = `\n${index + 1}. Rule: ${group.rule} (${group.elements.length} ${instanceLabel})\n`;
61
+ output += ` Message: ${group.message}\n`;
62
+
63
+ if (group.suggestion) {
64
+ output += ` Suggestion: ${group.suggestion}\n`;
65
+ }
66
+
67
+ output += ' Elements:\n';
68
+ group.elements.forEach((element) => {
69
+ output += ` - ${element}\n`;
70
+ });
71
+
72
+ return output;
73
+ }
74
+
75
+ export function formatConsoleReport(results, options = {}) {
76
+ if (!results) return '';
77
+
78
+ const { colors = true } = options;
79
+ let output = `\nSemantica11y Analysis Report\n`;
80
+ output += `URL: ${results.url}\n`;
81
+ output += `Time: ${results.timestamp}\n`;
82
+ output += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
83
+ output += `Total Findings: ${results.summary.total}\n`;
84
+ output += ` Errors: ${results.summary.errors}\n`;
85
+ output += ` Warnings: ${results.summary.warnings}\n`;
86
+ output += ` Suggestions: ${results.summary.suggestions}\n`;
87
+ output += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
88
+
89
+ if (results.issues.length > 0) {
90
+ output += `\n${colorize('Findings By Category', COLORS.bold, colors)}\n`;
91
+
92
+ ISSUE_SECTIONS.forEach(({ severity, title, color }) => {
93
+ const issues = results.issues.filter((issue) => issue.severity === severity);
94
+
95
+ output += `\n${colorize(`${title} (${issues.length})`, color, colors)}\n`;
96
+ output += `${'─'.repeat(28)}\n`;
97
+
98
+ if (issues.length === 0) {
99
+ output += `No ${title.toLowerCase()} found.\n`;
100
+ return;
101
+ }
102
+
103
+ groupIssues(issues).forEach((group, index) => {
104
+ output += formatIssueGroup(group, index);
105
+ });
106
+ });
107
+ } else {
108
+ output += `\nNo issues found!\n`;
109
+ }
110
+
111
+ return output;
112
+ }
113
+
114
+ export function printConsoleReport(results, options = {}) {
115
+ const report = formatConsoleReport(results, options);
116
+ console.log(report);
117
+ return report;
118
+ }
119
+
120
+ export async function exportTextReport(results, filePath, options = {}) {
121
+ const { colors = false } = options;
122
+ const report = formatConsoleReport(results, { colors });
123
+
124
+ await writeFile(filePath, report, 'utf8');
125
+
126
+ return filePath;
127
+ }