@nitpicker/core 0.4.1
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/CHANGELOG.md +8 -0
- package/LICENSE +191 -0
- package/README.md +13 -0
- package/lib/discover-analyze-plugins.d.ts +14 -0
- package/lib/discover-analyze-plugins.js +34 -0
- package/lib/find-nitpicker-modules-dir.d.ts +12 -0
- package/lib/find-nitpicker-modules-dir.js +23 -0
- package/lib/hooks/actions.d.ts +9 -0
- package/lib/hooks/actions.js +9 -0
- package/lib/hooks/child-process.d.ts +1 -0
- package/lib/hooks/child-process.js +34 -0
- package/lib/hooks/define-plugin.d.ts +68 -0
- package/lib/hooks/define-plugin.js +69 -0
- package/lib/hooks/index.d.ts +1 -0
- package/lib/hooks/index.js +1 -0
- package/lib/hooks/runner.d.ts +10 -0
- package/lib/hooks/runner.js +32 -0
- package/lib/import-modules.d.ts +24 -0
- package/lib/import-modules.js +38 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +5 -0
- package/lib/load-plugin-settings.d.ts +40 -0
- package/lib/load-plugin-settings.js +85 -0
- package/lib/nitpicker.d.ts +127 -0
- package/lib/nitpicker.js +338 -0
- package/lib/page-analysis-worker.d.ts +48 -0
- package/lib/page-analysis-worker.js +98 -0
- package/lib/read-plugin-labels.d.ts +15 -0
- package/lib/read-plugin-labels.js +30 -0
- package/lib/table.d.ts +75 -0
- package/lib/table.js +132 -0
- package/lib/types.d.ts +264 -0
- package/lib/types.js +1 -0
- package/lib/url-event-bus.d.ts +32 -0
- package/lib/url-event-bus.js +20 -0
- package/lib/utils.d.ts +36 -0
- package/lib/utils.js +43 -0
- package/lib/worker/run-in-worker.d.ts +51 -0
- package/lib/worker/run-in-worker.js +120 -0
- package/lib/worker/runner.d.ts +25 -0
- package/lib/worker/runner.js +31 -0
- package/lib/worker/types.d.ts +23 -0
- package/lib/worker/types.js +1 -0
- package/lib/worker/worker.d.ts +27 -0
- package/lib/worker/worker.js +53 -0
- package/package.json +36 -0
- package/src/discover-analyze-plugins.spec.ts +21 -0
- package/src/discover-analyze-plugins.ts +37 -0
- package/src/hooks/define-plugin.spec.ts +38 -0
- package/src/hooks/define-plugin.ts +73 -0
- package/src/hooks/index.ts +1 -0
- package/src/import-modules.spec.ts +150 -0
- package/src/import-modules.ts +45 -0
- package/src/index.ts +5 -0
- package/src/load-plugin-settings.spec.ts +192 -0
- package/src/load-plugin-settings.ts +99 -0
- package/src/nitpicker.ts +418 -0
- package/src/page-analysis-worker.spec.ts +287 -0
- package/src/page-analysis-worker.ts +131 -0
- package/src/read-plugin-labels.spec.ts +151 -0
- package/src/read-plugin-labels.ts +37 -0
- package/src/table.spec.ts +83 -0
- package/src/table.ts +149 -0
- package/src/types.ts +289 -0
- package/src/url-event-bus.spec.ts +28 -0
- package/src/url-event-bus.ts +33 -0
- package/src/worker/run-in-worker.ts +155 -0
- package/src/worker/runner.ts +38 -0
- package/src/worker/types.ts +25 -0
- package/src/worker/worker.ts +64 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/nitpicker.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import type { PageAnalysisWorkerData } from './page-analysis-worker.js';
|
|
2
|
+
import type {
|
|
3
|
+
AnalyzeOptions,
|
|
4
|
+
Config,
|
|
5
|
+
NitpickerEvent,
|
|
6
|
+
ReportPage,
|
|
7
|
+
TableData,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
import type { Report, Violation } from '@nitpicker/types';
|
|
10
|
+
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { Cache } from '@d-zero/shared/cache';
|
|
15
|
+
import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
|
|
16
|
+
import { Archive } from '@nitpicker/crawler';
|
|
17
|
+
import c from 'ansi-colors';
|
|
18
|
+
|
|
19
|
+
import { importModules } from './import-modules.js';
|
|
20
|
+
import { loadPluginSettings } from './load-plugin-settings.js';
|
|
21
|
+
import { Table } from './table.js';
|
|
22
|
+
import { UrlEventBus } from './url-event-bus.js';
|
|
23
|
+
import { runInWorker } from './worker/run-in-worker.js';
|
|
24
|
+
|
|
25
|
+
export { definePlugin } from './hooks/define-plugin.js';
|
|
26
|
+
|
|
27
|
+
const __filename = new URL(import.meta.url).pathname;
|
|
28
|
+
const __dirname = path.dirname(__filename);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolved path to the compiled Worker entry point.
|
|
32
|
+
* This file is loaded by `runInWorker()` in a `new Worker(...)` call.
|
|
33
|
+
* @see {@link ./page-analysis-worker.ts} for the source
|
|
34
|
+
*/
|
|
35
|
+
const pageAnalysisWorkerPath = path.resolve(__dirname, 'page-analysis-worker.js');
|
|
36
|
+
|
|
37
|
+
/** Maximum number of concurrent Worker threads per plugin. */
|
|
38
|
+
const CONCURRENCY_LIMIT = 50;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Core orchestrator for running analyze plugins against a `.nitpicker` archive.
|
|
42
|
+
*
|
|
43
|
+
* Nitpicker opens an existing archive (produced by the crawler), loads the
|
|
44
|
+
* user's plugin configuration via cosmiconfig, then runs each plugin against
|
|
45
|
+
* every page in the archive. Results are stored back into the archive as
|
|
46
|
+
* `analysis/report`, `analysis/table`, and `analysis/violations`.
|
|
47
|
+
*
|
|
48
|
+
* ## Architecture decisions
|
|
49
|
+
*
|
|
50
|
+
* - **Worker threads for `eachPage`**: DOM-heavy analysis (JSDOM + axe-core,
|
|
51
|
+
* markuplint, etc.) runs in isolated Worker threads so that a crashing plugin
|
|
52
|
+
* cannot take down the main process and memory from JSDOM windows is reclaimed
|
|
53
|
+
* when the Worker exits. See {@link ./worker/run-in-worker.ts!runInWorker}.
|
|
54
|
+
*
|
|
55
|
+
* - **Plugin-outer, page-inner loop**: Plugins are processed sequentially,
|
|
56
|
+
* and for each plugin, pages are processed in parallel (limit: 50) using
|
|
57
|
+
* a bounded Promise pool. This enables per-plugin progress tracking via
|
|
58
|
+
* {@link https://www.npmjs.com/package/@d-zero/dealer | Lanes}.
|
|
59
|
+
*
|
|
60
|
+
* - **Cache layer**: Results are cached per `pluginName:url` using
|
|
61
|
+
* `@d-zero/shared/cache` so that re-running analysis after a partial failure
|
|
62
|
+
* skips already-processed pages. The cache is cleared at the start of each run.
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* import { Nitpicker } from '@nitpicker/core';
|
|
66
|
+
*
|
|
67
|
+
* // Open an existing archive
|
|
68
|
+
* const nitpicker = await Nitpicker.open('./example.nitpicker');
|
|
69
|
+
*
|
|
70
|
+
* // Run all configured analyze plugins
|
|
71
|
+
* await nitpicker.analyze();
|
|
72
|
+
*
|
|
73
|
+
* // Or run only specific plugins by name
|
|
74
|
+
* await nitpicker.analyze(['@nitpicker/analyze-axe']);
|
|
75
|
+
*
|
|
76
|
+
* // Write updated archive back to disk
|
|
77
|
+
* await nitpicker.write();
|
|
78
|
+
* ```
|
|
79
|
+
* @see {@link ./types.ts!NitpickerEvent} for emitted events
|
|
80
|
+
* @see {@link ./types.ts!Config} for the resolved configuration model
|
|
81
|
+
*/
|
|
82
|
+
export class Nitpicker extends EventEmitter<NitpickerEvent> {
|
|
83
|
+
/**
|
|
84
|
+
* The underlying archive instance providing access to the SQLite database
|
|
85
|
+
* and file storage. Injected via constructor or created by `Nitpicker.open()`.
|
|
86
|
+
*/
|
|
87
|
+
readonly #archive: Archive;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Lazily loaded and cached plugin configuration.
|
|
91
|
+
* `null` until `getConfig()` is first called.
|
|
92
|
+
*/
|
|
93
|
+
#config: Config | null = null;
|
|
94
|
+
|
|
95
|
+
/** The underlying archive instance. */
|
|
96
|
+
get archive() {
|
|
97
|
+
return this.#archive;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param archive - An opened {@link Archive} instance to analyze.
|
|
102
|
+
* Use {@link Nitpicker.open} for a convenient static factory.
|
|
103
|
+
*/
|
|
104
|
+
constructor(archive: Archive) {
|
|
105
|
+
super();
|
|
106
|
+
this.#archive = archive;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Runs all configured analyze plugins (or a filtered subset) against
|
|
111
|
+
* every page in the archive.
|
|
112
|
+
*
|
|
113
|
+
* Plugins are processed **sequentially** (one at a time), while pages
|
|
114
|
+
* within each plugin are processed in **parallel** (bounded to
|
|
115
|
+
* {@link CONCURRENCY_LIMIT}). This architecture enables per-plugin
|
|
116
|
+
* progress tracking via Lanes.
|
|
117
|
+
*
|
|
118
|
+
* The analysis proceeds in two phases:
|
|
119
|
+
*
|
|
120
|
+
* 1. **`eachPage` phase** - For each plugin with `eachPage`, spawns
|
|
121
|
+
* Worker threads via a bounded Promise pool to analyze all pages.
|
|
122
|
+
* Progress is displayed via Lanes if provided in options.
|
|
123
|
+
*
|
|
124
|
+
* 2. **`eachUrl` phase** - For all plugins with `eachUrl`, runs
|
|
125
|
+
* sequentially in the main thread. These are lightweight checks
|
|
126
|
+
* that don't need DOM access.
|
|
127
|
+
*
|
|
128
|
+
* On completion, three data entries are stored in the archive:
|
|
129
|
+
* - `analysis/report` - Full {@link Report} with headers, data, and violations
|
|
130
|
+
* - `analysis/table` - The raw {@link Table} instance (serialized)
|
|
131
|
+
* - `analysis/violations` - Flat array of all {@link Violation} records
|
|
132
|
+
* @param filter - Optional list of plugin module names to run.
|
|
133
|
+
* If omitted, all configured plugins are executed.
|
|
134
|
+
* @param options - Optional settings for progress display.
|
|
135
|
+
* @example
|
|
136
|
+
* ```ts
|
|
137
|
+
* // Run all plugins
|
|
138
|
+
* await nitpicker.analyze();
|
|
139
|
+
*
|
|
140
|
+
* // Run only axe and markuplint with Lanes progress
|
|
141
|
+
* await nitpicker.analyze(
|
|
142
|
+
* ['@nitpicker/analyze-axe', '@nitpicker/analyze-markuplint'],
|
|
143
|
+
* { lanes },
|
|
144
|
+
* );
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
async analyze(filter?: string[], options?: AnalyzeOptions) {
|
|
148
|
+
const config = await this.getConfig();
|
|
149
|
+
const plugins = filter
|
|
150
|
+
? config.analyze.filter((plugin) => filter?.includes(plugin.name))
|
|
151
|
+
: config.analyze;
|
|
152
|
+
|
|
153
|
+
const analyzeMods = await importModules(plugins);
|
|
154
|
+
const lanes = options?.lanes;
|
|
155
|
+
|
|
156
|
+
const table = new Table();
|
|
157
|
+
|
|
158
|
+
for (const mod of analyzeMods) {
|
|
159
|
+
if (!mod.headers) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (!mod.eachPage) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
table.addHeaders(mod.headers);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const allViolations: Violation[] = [];
|
|
170
|
+
const cache = new Cache<{
|
|
171
|
+
pages?: Record<string, TableData<string>>;
|
|
172
|
+
violations?: Violation[];
|
|
173
|
+
}>('nitpicker-axe', path.join(os.tmpdir(), 'nitpicker/cache/table'));
|
|
174
|
+
|
|
175
|
+
await cache.clear();
|
|
176
|
+
|
|
177
|
+
// Build plugin metadata: lane IDs and display labels
|
|
178
|
+
const eachPagePlugins: Array<{ plugin: (typeof plugins)[number]; modIndex: number }> =
|
|
179
|
+
[];
|
|
180
|
+
for (const [i, plugin] of plugins.entries()) {
|
|
181
|
+
if (analyzeMods[i]?.eachPage) {
|
|
182
|
+
eachPagePlugins.push({ plugin, modIndex: i });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const pluginLaneIds = new Map<string, number>();
|
|
187
|
+
const pluginLabels = new Map<string, string>();
|
|
188
|
+
const pluginCompletionDetails = new Map<string, string>();
|
|
189
|
+
|
|
190
|
+
for (const [laneId, { plugin, modIndex }] of eachPagePlugins.entries()) {
|
|
191
|
+
pluginLaneIds.set(plugin.name, laneId);
|
|
192
|
+
pluginLabels.set(plugin.name, analyzeMods[modIndex]?.label ?? plugin.name);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Initialize all lanes as Waiting
|
|
196
|
+
for (const [name, id] of pluginLaneIds) {
|
|
197
|
+
const label = pluginLabels.get(name) ?? name;
|
|
198
|
+
lanes?.update(id, c.dim(`${label}: Waiting...`));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await this.archive.getPagesWithRefs(
|
|
202
|
+
100_000,
|
|
203
|
+
async (pages) => {
|
|
204
|
+
const urlEmitter = new UrlEventBus();
|
|
205
|
+
|
|
206
|
+
// Phase 1: eachPage plugins (sequentially, pages in parallel)
|
|
207
|
+
for (const [pluginSeqIndex, { plugin }] of eachPagePlugins.entries()) {
|
|
208
|
+
const laneId = pluginLaneIds.get(plugin.name)!;
|
|
209
|
+
const label = pluginLabels.get(plugin.name) ?? plugin.name;
|
|
210
|
+
let done = 0;
|
|
211
|
+
let pluginViolationCount = 0;
|
|
212
|
+
|
|
213
|
+
const updateProgress = () => {
|
|
214
|
+
const pluginPercent = Math.round((done / pages.length) * 100);
|
|
215
|
+
const overallPercent = Math.round(
|
|
216
|
+
((pluginSeqIndex + done / pages.length) / eachPagePlugins.length) * 100,
|
|
217
|
+
);
|
|
218
|
+
lanes?.header(
|
|
219
|
+
`[${pluginSeqIndex + 1}/${eachPagePlugins.length}] Analyzing (${overallPercent}%)`,
|
|
220
|
+
);
|
|
221
|
+
lanes?.update(
|
|
222
|
+
laneId,
|
|
223
|
+
`${label}: ${done}/${pages.length} (${pluginPercent}%)%braille%`,
|
|
224
|
+
);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
updateProgress();
|
|
228
|
+
|
|
229
|
+
// Bounded Promise pool (replaces deal())
|
|
230
|
+
const executing = new Set<Promise<void>>();
|
|
231
|
+
|
|
232
|
+
for (const [pageIndex, page] of pages.entries()) {
|
|
233
|
+
const task = (async () => {
|
|
234
|
+
const cacheKey = `${plugin.name}:${page.url.href}`;
|
|
235
|
+
const cached = await cache.load(cacheKey);
|
|
236
|
+
if (cached) {
|
|
237
|
+
const { pages: cachedPages, violations } = cached;
|
|
238
|
+
if (cachedPages) {
|
|
239
|
+
table.addData(cachedPages);
|
|
240
|
+
}
|
|
241
|
+
if (violations) {
|
|
242
|
+
allViolations.push(...violations);
|
|
243
|
+
pluginViolationCount += violations.length;
|
|
244
|
+
}
|
|
245
|
+
done++;
|
|
246
|
+
updateProgress();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const html = await page.getHtml();
|
|
251
|
+
if (!html) {
|
|
252
|
+
done++;
|
|
253
|
+
updateProgress();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const report = await runInWorker<
|
|
258
|
+
PageAnalysisWorkerData,
|
|
259
|
+
ReportPage<string> | null
|
|
260
|
+
>({
|
|
261
|
+
filePath: pageAnalysisWorkerPath,
|
|
262
|
+
num: pageIndex,
|
|
263
|
+
total: pages.length,
|
|
264
|
+
emitter: urlEmitter,
|
|
265
|
+
initialData: {
|
|
266
|
+
plugin,
|
|
267
|
+
pages: {
|
|
268
|
+
html,
|
|
269
|
+
url: page.url,
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const tablePages: Record<string, TableData<string>> = {};
|
|
275
|
+
|
|
276
|
+
if (report?.page) {
|
|
277
|
+
tablePages[page.url.href] = report.page;
|
|
278
|
+
table.addDataToUrl(page.url, report.page);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
await cache.store(cacheKey, {
|
|
282
|
+
pages: Object.keys(tablePages).length > 0 ? tablePages : undefined,
|
|
283
|
+
violations: report?.violations,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
if (report?.violations) {
|
|
287
|
+
allViolations.push(...report.violations);
|
|
288
|
+
pluginViolationCount += report.violations.length;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
done++;
|
|
292
|
+
updateProgress();
|
|
293
|
+
})();
|
|
294
|
+
executing.add(task);
|
|
295
|
+
task.then(
|
|
296
|
+
() => executing.delete(task),
|
|
297
|
+
() => executing.delete(task),
|
|
298
|
+
);
|
|
299
|
+
if (executing.size >= CONCURRENCY_LIMIT) {
|
|
300
|
+
await Promise.race(executing);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await Promise.all(executing);
|
|
305
|
+
|
|
306
|
+
// Mark this plugin as Done
|
|
307
|
+
const detail =
|
|
308
|
+
pluginViolationCount > 0
|
|
309
|
+
? `${pluginViolationCount} violations`
|
|
310
|
+
: `${done} pages`;
|
|
311
|
+
pluginCompletionDetails.set(plugin.name, detail);
|
|
312
|
+
lanes?.update(laneId, c.green(`${label}: Done (${detail})`));
|
|
313
|
+
|
|
314
|
+
// Dim inactive lanes
|
|
315
|
+
for (const [name, id] of pluginLaneIds) {
|
|
316
|
+
if (name === plugin.name) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const otherLabel = pluginLabels.get(name) ?? name;
|
|
320
|
+
const completionDetail = pluginCompletionDetails.get(name);
|
|
321
|
+
if (completionDetail) {
|
|
322
|
+
lanes?.update(id, c.dim(`${otherLabel}: Done (${completionDetail})`));
|
|
323
|
+
} else {
|
|
324
|
+
lanes?.update(id, c.dim(`${otherLabel}: Waiting...`));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Phase 2: eachUrl plugins (main thread, sequential)
|
|
330
|
+
for (const page of pages) {
|
|
331
|
+
const url = page.url;
|
|
332
|
+
const isExternal = page.isExternal;
|
|
333
|
+
|
|
334
|
+
for (const mod of analyzeMods) {
|
|
335
|
+
if (!mod.eachUrl) {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const report = await mod.eachUrl({ url, isExternal });
|
|
340
|
+
if (!report) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const { page: reportPage, violations } = report;
|
|
345
|
+
|
|
346
|
+
if (reportPage) {
|
|
347
|
+
table.addDataToUrl(url, reportPage);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (violations) {
|
|
351
|
+
allViolations.push(...violations);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
withRefs: false,
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
const report: Report = {
|
|
362
|
+
name: 'general',
|
|
363
|
+
pageData: table.toJSON(),
|
|
364
|
+
violations: allViolations,
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
await this.archive.setData('analysis/report', report);
|
|
368
|
+
await this.archive.setData('analysis/table', table);
|
|
369
|
+
await this.archive.setData('analysis/violations', allViolations);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Loads and caches the plugin configuration from the user's config file.
|
|
374
|
+
*
|
|
375
|
+
* Uses cosmiconfig to search for `.nitpickerrc`, `.nitpickerrc.json`,
|
|
376
|
+
* `nitpicker.config.js`, or a `nitpicker` key in `package.json`.
|
|
377
|
+
* The result is cached after the first call.
|
|
378
|
+
* @returns Resolved {@link Config} with the `analyze` plugin list.
|
|
379
|
+
*/
|
|
380
|
+
async getConfig() {
|
|
381
|
+
if (!this.#config) {
|
|
382
|
+
this.#config = await loadPluginSettings();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return this.#config;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Writes the archive (including any new analysis results) to disk
|
|
390
|
+
* as a `.nitpicker` tar file, then emits a `writeFile` event.
|
|
391
|
+
* @fires NitpickerEvent#writeFile
|
|
392
|
+
*/
|
|
393
|
+
async write() {
|
|
394
|
+
await this.#archive.write();
|
|
395
|
+
await this.emit('writeFile', { filePath: this.#archive.filePath });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Opens an existing `.nitpicker` archive file and returns a ready-to-use
|
|
400
|
+
* Nitpicker instance.
|
|
401
|
+
*
|
|
402
|
+
* This is the recommended way to create a Nitpicker instance. It extracts
|
|
403
|
+
* the archive to a temporary directory, opens the SQLite database, and
|
|
404
|
+
* enables plugin data access.
|
|
405
|
+
* @param filePath - Path to the `.nitpicker` archive file.
|
|
406
|
+
* @returns A new Nitpicker instance backed by the opened archive.
|
|
407
|
+
* @example
|
|
408
|
+
* ```ts
|
|
409
|
+
* const nitpicker = await Nitpicker.open('./site.nitpicker');
|
|
410
|
+
* await nitpicker.analyze();
|
|
411
|
+
* await nitpicker.write();
|
|
412
|
+
* ```
|
|
413
|
+
*/
|
|
414
|
+
static async open(filePath: string) {
|
|
415
|
+
const archive = await Archive.open({ filePath, openPluginData: true });
|
|
416
|
+
return new Nitpicker(archive);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { UrlEventBus } from './url-event-bus.js';
|
|
4
|
+
|
|
5
|
+
vi.mock('./import-modules.js', () => ({
|
|
6
|
+
importModules: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
const { importModules } = await import('./import-modules.js');
|
|
10
|
+
const mockedImportModules = vi.mocked(importModules);
|
|
11
|
+
|
|
12
|
+
const { parseUrl } = await import('@d-zero/shared/parse-url');
|
|
13
|
+
|
|
14
|
+
describe('page-analysis-worker', () => {
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
16
|
+
let workerFn: typeof import('./page-analysis-worker.js').default;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
const mod = await import('./page-analysis-worker.js');
|
|
21
|
+
workerFn = mod.default;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns null when plugin has no eachPage', async () => {
|
|
25
|
+
mockedImportModules.mockResolvedValue([{ label: 'No-op plugin' }]);
|
|
26
|
+
|
|
27
|
+
const result = await workerFn(
|
|
28
|
+
{
|
|
29
|
+
plugin: {
|
|
30
|
+
name: 'test-plugin',
|
|
31
|
+
module: 'test-plugin',
|
|
32
|
+
configFilePath: '',
|
|
33
|
+
},
|
|
34
|
+
pages: {
|
|
35
|
+
html: '<html><body>Hello</body></html>',
|
|
36
|
+
url: parseUrl('https://example.com/'),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
new UrlEventBus(),
|
|
40
|
+
0,
|
|
41
|
+
1,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(result).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('calls eachPage with correct arguments and returns report', async () => {
|
|
48
|
+
let capturedUrl: unknown;
|
|
49
|
+
let capturedHtml: unknown;
|
|
50
|
+
let capturedNum: unknown;
|
|
51
|
+
let capturedTotal: unknown;
|
|
52
|
+
let hadWindow = false;
|
|
53
|
+
let hadDocument = false;
|
|
54
|
+
|
|
55
|
+
const eachPage = vi.fn().mockImplementation((arg: Record<string, unknown>) => {
|
|
56
|
+
// Capture state inside callback before JSDOM cleanup in finally block
|
|
57
|
+
capturedUrl = arg.url;
|
|
58
|
+
capturedHtml = arg.html;
|
|
59
|
+
capturedNum = arg.num;
|
|
60
|
+
capturedTotal = arg.total;
|
|
61
|
+
hadWindow = arg.window != null;
|
|
62
|
+
hadDocument = (arg.window as { document?: unknown } | undefined)?.document != null;
|
|
63
|
+
return {
|
|
64
|
+
page: { title: { value: 'Test' } },
|
|
65
|
+
violations: [{ message: 'test violation', severity: 'error' }],
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
mockedImportModules.mockResolvedValue([{ eachPage }]);
|
|
69
|
+
|
|
70
|
+
const url = parseUrl('https://example.com/page');
|
|
71
|
+
const html = '<html><body><h1>Hello</h1></body></html>';
|
|
72
|
+
|
|
73
|
+
const result = await workerFn(
|
|
74
|
+
{
|
|
75
|
+
plugin: {
|
|
76
|
+
name: 'test-plugin',
|
|
77
|
+
module: 'test-plugin',
|
|
78
|
+
configFilePath: '',
|
|
79
|
+
},
|
|
80
|
+
pages: { html, url },
|
|
81
|
+
},
|
|
82
|
+
new UrlEventBus(),
|
|
83
|
+
3,
|
|
84
|
+
10,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(eachPage).toHaveBeenCalledOnce();
|
|
88
|
+
expect(capturedUrl).toBe(url);
|
|
89
|
+
expect(capturedHtml).toBe(html);
|
|
90
|
+
expect(capturedNum).toBe(3);
|
|
91
|
+
expect(capturedTotal).toBe(10);
|
|
92
|
+
expect(hadWindow).toBe(true);
|
|
93
|
+
expect(hadDocument).toBe(true);
|
|
94
|
+
|
|
95
|
+
expect(result).toEqual({
|
|
96
|
+
page: { title: { value: 'Test' } },
|
|
97
|
+
violations: [{ message: 'test violation', severity: 'error' }],
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('emits url event on UrlEventBus', async () => {
|
|
102
|
+
mockedImportModules.mockResolvedValue([
|
|
103
|
+
{ eachPage: vi.fn().mockResolvedValue(null) },
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const bus = new UrlEventBus();
|
|
107
|
+
const handler = vi.fn();
|
|
108
|
+
bus.on('url', handler);
|
|
109
|
+
|
|
110
|
+
await workerFn(
|
|
111
|
+
{
|
|
112
|
+
plugin: {
|
|
113
|
+
name: 'test-plugin',
|
|
114
|
+
module: 'test-plugin',
|
|
115
|
+
configFilePath: '',
|
|
116
|
+
},
|
|
117
|
+
pages: {
|
|
118
|
+
html: '<html></html>',
|
|
119
|
+
url: parseUrl('https://example.com/test'),
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
bus,
|
|
123
|
+
0,
|
|
124
|
+
1,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(handler).toHaveBeenCalledWith('https://example.com/test');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('returns null when eachPage returns undefined', async () => {
|
|
131
|
+
mockedImportModules.mockResolvedValue([{ eachPage: vi.fn().mockResolvedValue() }]);
|
|
132
|
+
|
|
133
|
+
const result = await workerFn(
|
|
134
|
+
{
|
|
135
|
+
plugin: {
|
|
136
|
+
name: 'test-plugin',
|
|
137
|
+
module: 'test-plugin',
|
|
138
|
+
configFilePath: '',
|
|
139
|
+
},
|
|
140
|
+
pages: {
|
|
141
|
+
html: '<html></html>',
|
|
142
|
+
url: parseUrl('https://example.com/'),
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
new UrlEventBus(),
|
|
146
|
+
0,
|
|
147
|
+
1,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(result).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('returns null and logs error when eachPage throws', async () => {
|
|
154
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
155
|
+
mockedImportModules.mockResolvedValue([
|
|
156
|
+
{
|
|
157
|
+
eachPage: vi.fn().mockRejectedValue(new Error('plugin crash')),
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
const result = await workerFn(
|
|
162
|
+
{
|
|
163
|
+
plugin: {
|
|
164
|
+
name: 'broken-plugin',
|
|
165
|
+
module: 'broken-plugin',
|
|
166
|
+
configFilePath: '',
|
|
167
|
+
},
|
|
168
|
+
pages: {
|
|
169
|
+
html: '<html></html>',
|
|
170
|
+
url: parseUrl('https://example.com/'),
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
new UrlEventBus(),
|
|
174
|
+
0,
|
|
175
|
+
1,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(result).toBeNull();
|
|
179
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith('[broken-plugin] plugin crash');
|
|
180
|
+
consoleErrorSpy.mockRestore();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('cleans up JSDOM globals after eachPage completes', async () => {
|
|
184
|
+
let capturedKeys: string[] = [];
|
|
185
|
+
mockedImportModules.mockResolvedValue([
|
|
186
|
+
{
|
|
187
|
+
eachPage: vi.fn().mockImplementation(() => {
|
|
188
|
+
// Capture some JSDOM-injected globals during execution
|
|
189
|
+
capturedKeys = Object.getOwnPropertyNames(globalThis).filter(
|
|
190
|
+
(k) => k === 'HTMLElement' || k === 'NodeList',
|
|
191
|
+
);
|
|
192
|
+
return null;
|
|
193
|
+
}),
|
|
194
|
+
},
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
await workerFn(
|
|
198
|
+
{
|
|
199
|
+
plugin: {
|
|
200
|
+
name: 'test-plugin',
|
|
201
|
+
module: 'test-plugin',
|
|
202
|
+
configFilePath: '',
|
|
203
|
+
},
|
|
204
|
+
pages: {
|
|
205
|
+
html: '<html><body></body></html>',
|
|
206
|
+
url: parseUrl('https://example.com/'),
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
new UrlEventBus(),
|
|
210
|
+
0,
|
|
211
|
+
1,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// JSDOM globals should be available during eachPage
|
|
215
|
+
expect(capturedKeys.length).toBeGreaterThan(0);
|
|
216
|
+
|
|
217
|
+
// But cleaned up after
|
|
218
|
+
const g = globalThis as Record<string, unknown>;
|
|
219
|
+
expect(g['HTMLElement']).toBeUndefined();
|
|
220
|
+
expect(g['NodeList']).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('cleans up JSDOM globals even when eachPage throws', async () => {
|
|
224
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
225
|
+
mockedImportModules.mockResolvedValue([
|
|
226
|
+
{
|
|
227
|
+
eachPage: vi.fn().mockRejectedValue(new Error('crash')),
|
|
228
|
+
},
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
await workerFn(
|
|
232
|
+
{
|
|
233
|
+
plugin: {
|
|
234
|
+
name: 'test-plugin',
|
|
235
|
+
module: 'test-plugin',
|
|
236
|
+
configFilePath: '',
|
|
237
|
+
},
|
|
238
|
+
pages: {
|
|
239
|
+
html: '<html><body></body></html>',
|
|
240
|
+
url: parseUrl('https://example.com/'),
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
new UrlEventBus(),
|
|
244
|
+
0,
|
|
245
|
+
1,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const g = globalThis as Record<string, unknown>;
|
|
249
|
+
expect(g['HTMLElement']).toBeUndefined();
|
|
250
|
+
vi.restoreAllMocks();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('provides a JSDOM window with the correct URL', async () => {
|
|
254
|
+
let receivedUrl = '';
|
|
255
|
+
mockedImportModules.mockResolvedValue([
|
|
256
|
+
{
|
|
257
|
+
eachPage: vi
|
|
258
|
+
.fn()
|
|
259
|
+
.mockImplementation(
|
|
260
|
+
({ window }: { window: { location: { href: string } } }) => {
|
|
261
|
+
receivedUrl = window.location.href;
|
|
262
|
+
return null;
|
|
263
|
+
},
|
|
264
|
+
),
|
|
265
|
+
},
|
|
266
|
+
]);
|
|
267
|
+
|
|
268
|
+
await workerFn(
|
|
269
|
+
{
|
|
270
|
+
plugin: {
|
|
271
|
+
name: 'test-plugin',
|
|
272
|
+
module: 'test-plugin',
|
|
273
|
+
configFilePath: '',
|
|
274
|
+
},
|
|
275
|
+
pages: {
|
|
276
|
+
html: '<html></html>',
|
|
277
|
+
url: parseUrl('https://example.com/specific-page'),
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
new UrlEventBus(),
|
|
281
|
+
0,
|
|
282
|
+
1,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
expect(receivedUrl).toBe('https://example.com/specific-page');
|
|
286
|
+
});
|
|
287
|
+
});
|