@fusion.dev/helix-diagnostics 0.139.0 → 0.140.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/README.md +72 -1
- package/dist/ci.js +14 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +12 -1
- package/dist/console.d.ts +2 -0
- package/dist/console.js +33 -2
- package/dist/diagnose.js +18 -1
- package/dist/identity.d.ts +6 -0
- package/dist/identity.js +1 -0
- package/dist/init.js +15 -4
- package/dist/overlay.js +36 -14
- package/dist/performance.d.ts +34 -0
- package/dist/performance.js +121 -0
- package/dist/report.d.ts +79 -0
- package/dist/runtime.d.ts +31 -0
- package/dist/runtime.js +39 -0
- package/dist/usage.d.ts +25 -3
- package/dist/usage.js +28 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -49,6 +49,13 @@ so React's strict mode and hot module replacement start nothing twice.
|
|
|
49
49
|
counts them and opens a list of each finding with its evidence. Hover over a
|
|
50
50
|
finding, or focus it, to outline the element it is about. The badge shows
|
|
51
51
|
only while the page has findings.
|
|
52
|
+
- **Hears what the library reports as it runs**, such as a selectable table
|
|
53
|
+
with no `row-key`, and lists each event in the run, the badge, and the relay.
|
|
54
|
+
Core still prints each one as it happens, with the stack it happened in. Set
|
|
55
|
+
`core: { warnings: false }` to have diagnostics print them in its group
|
|
56
|
+
instead.
|
|
57
|
+
- **Measures what the components cost, where you ask**: each tag's first
|
|
58
|
+
updates, layout shift, and long tasks. See [Performance](#performance).
|
|
52
59
|
- **Hands each run to your app**: the `onReport` option, `subscribe()` on the
|
|
53
60
|
handle it returns, and a `helix-diagnostics` event on `document`.
|
|
54
61
|
|
|
@@ -72,7 +79,7 @@ throws stops neither the others nor the console.
|
|
|
72
79
|
| `onReport` | Called with each run |
|
|
73
80
|
| `duplicates` | `duplicateRegistrations` from `@fusion.dev/helix-core` |
|
|
74
81
|
| `file` | Your project's `helix-diagnostics.json`, imported |
|
|
75
|
-
| Any setting below | `rules`, `console`, `sinks`, `send`, `detail`, `environments`, `project` |
|
|
82
|
+
| Any setting below | `rules`, `console`, `core`, `sinks`, `send`, `detail`, `environments`, `project` |
|
|
76
83
|
|
|
77
84
|
The handle has `active`, `latest`, `run()`, `subscribe()`, and `stop()`. An
|
|
78
85
|
end-to-end test reaches it at `globalThis[Symbol.for('fusion.dev/helix/diagnostics')]`
|
|
@@ -163,6 +170,13 @@ A report is one run, in one shape for every sink, versioned by `schema`:
|
|
|
163
170
|
`integrity`, or `guideline`), an `owner` (`consumer`, or `helix` for a fault
|
|
164
171
|
in the library), a `fingerprint` that stays the same from one run to the next
|
|
165
172
|
for the same fault, and the rule's own text as evidence.
|
|
173
|
+
- **`performance`**: what the components cost, by tag, where you asked for it.
|
|
174
|
+
See [Performance](#performance).
|
|
175
|
+
- **`runtime`**: what the library reported going wrong as it ran on the page,
|
|
176
|
+
from its first load. One entry per distinct event, with its `kind`, its
|
|
177
|
+
`tag`, the sentence that says what to do, how many times it was reported,
|
|
178
|
+
and whether core printed it. A `fingerprint` stays the same from one run to
|
|
179
|
+
the next, with the numbers in the sentence left out.
|
|
166
180
|
- **`usage`**: the Helix elements on the page by tag, the axes set on them
|
|
167
181
|
(`emphasis`, `material`, `separation`, and `tone`), the named slots filled,
|
|
168
182
|
the elements Helix renders inside other Helix elements, and the parts and
|
|
@@ -188,9 +202,11 @@ works on it:
|
|
|
188
202
|
| A finding at `error` | `console.error` |
|
|
189
203
|
| A finding at `warn` | `console.warn` |
|
|
190
204
|
| A finding at `info` | `console.info` |
|
|
205
|
+
| A runtime event core left to diagnostics | `console.warn`, or `console.error` for a failure |
|
|
191
206
|
| Stylesheets that could not be read | `console.warn` |
|
|
192
207
|
| Coverage, and findings turned off | `console.info` |
|
|
193
208
|
| Usage | `console.debug`, hidden at the default level |
|
|
209
|
+
| Performance, where you asked for it | `console.info`, when the measures change |
|
|
194
210
|
|
|
195
211
|
## Finding kinds
|
|
196
212
|
|
|
@@ -216,6 +232,58 @@ for another route.
|
|
|
216
232
|
attributes, `aria-*`, `data-*`, and the prefixes frameworks write are never
|
|
217
233
|
findings. The markup Helix renders inside its own components is Helix's.
|
|
218
234
|
|
|
235
|
+
## Runtime events
|
|
236
|
+
|
|
237
|
+
Helix reports these itself as it runs, through one channel in
|
|
238
|
+
`@fusion.dev/helix-core`. Each is printed in the console as it happens, and
|
|
239
|
+
diagnostics counts it by kind and tag. None fails a CI job.
|
|
240
|
+
|
|
241
|
+
| Kind | Means |
|
|
242
|
+
| --- | --- |
|
|
243
|
+
| `duplicate-registration` | Two copies of Helix are on the page. Also the finding of the same name |
|
|
244
|
+
| `fullscreen-refused` | The browser refused fullscreen, usually in a frame without `allow="fullscreen"` |
|
|
245
|
+
| `max-past-fold` | An avatar group's `max` is past the number its fold is written for |
|
|
246
|
+
| `min-out-of-range` | A rating's `min` is not a score between 0 and its `max` |
|
|
247
|
+
| `misplaced-handler` | A React `on` prop names an event that another element dispatches |
|
|
248
|
+
| `missing-layout` | The three.js graph driver could not load `d3-force-3d` |
|
|
249
|
+
| `missing-row-key` | A selectable data table has no `row-key`, so it knows each row by its place |
|
|
250
|
+
| `missing-stencil-parts` | A graph stencil names parts its shape does not have |
|
|
251
|
+
| `missing-trigger` | A tooltip's `for` names no element in its own tree |
|
|
252
|
+
| `negative-value` | An area chart was given a negative number, which it draws as none |
|
|
253
|
+
| `not-a-graph` | `connectGraph` was given an element that is not a graph frame |
|
|
254
|
+
| `picture-in-picture-refused` | The browser refused picture in picture |
|
|
255
|
+
| `removed-callback` | A React prop is a callback that was removed and never ran from JSX |
|
|
256
|
+
| `solid-handler-syntax` | A React prop uses Solid's `on:` syntax |
|
|
257
|
+
| `stencil-regions-unused` | A graph stencil's regions could not be used |
|
|
258
|
+
| `stencil-without-inside` | A graph stencil has no inside to place the notes in |
|
|
259
|
+
| `too-many-rows` | A data table is drawing more rows at once than it stays responsive to |
|
|
260
|
+
| `two-default-operators` | A filter field marks more than one operator as its default |
|
|
261
|
+
| `unknown-event` | A React `on` prop names an event that no element dispatches |
|
|
262
|
+
| `unlinked-trigger` | A tooltip's trigger does not name it in `aria-describedby` |
|
|
263
|
+
| `unsettled-window` | A virtualized list's box has no height of its own |
|
|
264
|
+
| `untold-trigger` | A slotted trigger is a plain element, which cannot carry `aria-expanded` |
|
|
265
|
+
| `wrapped-trigger` | A tooltip wraps its trigger, so its text reaches no screen reader |
|
|
266
|
+
|
|
267
|
+
## Performance
|
|
268
|
+
|
|
269
|
+
Diagnostics measures what the components cost only where you ask for it. Set
|
|
270
|
+
`console: { performance: 'info' }` to print the measures in DevTools, or
|
|
271
|
+
`send: { performance: true }` to keep them in each run, for your app, the
|
|
272
|
+
relay, and the CI file. For each tag rendered on the page, it measures:
|
|
273
|
+
|
|
274
|
+
- **First updates**: each element's own first update, from `willUpdate` to
|
|
275
|
+
`updated`, as a count, a median, a 95th percentile, and the slowest. Time an
|
|
276
|
+
element spends waiting behind other elements is not counted.
|
|
277
|
+
- **Layout shift**: the shift its elements moved, summed as the browser scores
|
|
278
|
+
it. A shift that follows a press or a keystroke is left out.
|
|
279
|
+
- **Long tasks**: how many tasks of 50 ms or more one of its first updates ran
|
|
280
|
+
in.
|
|
281
|
+
|
|
282
|
+
Core times each first update only while diagnostics is measuring, so a page
|
|
283
|
+
that has not asked pays one property read per element. Layout shift and long
|
|
284
|
+
tasks are measured in Chromium only. Elsewhere `measured` says so, and their
|
|
285
|
+
numbers are left out.
|
|
286
|
+
|
|
219
287
|
## Configure it
|
|
220
288
|
|
|
221
289
|
Every setting can be given in three places, and a later place wins, field by
|
|
@@ -250,6 +318,9 @@ diagnose({
|
|
|
250
318
|
| `console.findings` | `error`, `warn`, `info`, or `off`: the lowest severity printed | `info` |
|
|
251
319
|
| `console.coverage` | `info` or `off` | `info` |
|
|
252
320
|
| `console.usage` | `debug` or `off` | `debug` |
|
|
321
|
+
| `console.runtime` | `warn` or `off`: the runtime events core left to diagnostics | `warn` |
|
|
322
|
+
| `console.performance` | `info` or `off`: what the components cost, which is measured where this is on | `off` |
|
|
323
|
+
| `core.warnings` | `true` or `false`: whether core prints each runtime event as it happens | `true` |
|
|
253
324
|
| `sinks.overlay` | `true` or `false`, for the badge in development | `true` |
|
|
254
325
|
| `sinks.relay` | `true`, `false`, or an address on a local host, such as `http://127.0.0.1:7417` | `false` |
|
|
255
326
|
| `send.<category>` | `true` or `false`, for `findings`, `usage`, `coverage`, `configuration`, `runtime`, `performance`, and `assist` | all on but `performance` |
|
package/dist/ci.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
+
import { shownRuntime } from './runtime.js';
|
|
3
4
|
const RANK = { error: 3, info: 1, warn: 2 };
|
|
4
5
|
const counted = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
5
6
|
export const ciSink = (runs, { file, failOn = 'error' }) => {
|
|
@@ -17,8 +18,17 @@ export const ciSink = (runs, { file, failOn = 'error' }) => {
|
|
|
17
18
|
const of = (severity) => all.filter(({ finding }) => finding.severity === severity).length;
|
|
18
19
|
const failing = failOn === 'never' ? [] : all.filter(({ finding }) => RANK[finding.severity] >= RANK[failOn]);
|
|
19
20
|
const unread = runs.reduce((total, run) => total + run.coverage.unreadable.length, 0);
|
|
21
|
+
const events = new Map();
|
|
22
|
+
for (const run of runs) {
|
|
23
|
+
for (const entry of shownRuntime(run.runtime ?? [])) {
|
|
24
|
+
const event = events.get(entry.fingerprint) ?? { entry, pages: new Set() };
|
|
25
|
+
event.pages.add(run.run.page ?? '(no page)');
|
|
26
|
+
events.set(entry.fingerprint, event);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
20
29
|
const lines = [
|
|
21
30
|
`helix diagnostics: ${counted(runs.length, 'run')}, ${counted(of('error'), 'error')}, ${counted(of('warn'), 'warning')}, ${of('info')} info` +
|
|
31
|
+
(events.size > 0 ? `, ${events.size} runtime` : '') +
|
|
22
32
|
(unread > 0 ? `, ${counted(unread, 'unreadable stylesheet')}` : '') +
|
|
23
33
|
`. Written to ${file}.`,
|
|
24
34
|
];
|
|
@@ -26,5 +36,9 @@ export const ciSink = (runs, { file, failOn = 'error' }) => {
|
|
|
26
36
|
const where = [...pages].sort();
|
|
27
37
|
lines.push(` ${finding.severity} ${finding.kind}: ${finding.subject}`, ` ${finding.message}`, ` on ${where.slice(0, 5).join(', ')}${where.length > 5 ? `, and ${where.length - 5} more` : ''}`);
|
|
28
38
|
}
|
|
39
|
+
for (const { entry, pages } of events.values()) {
|
|
40
|
+
const where = [...pages].sort();
|
|
41
|
+
lines.push(` runtime ${entry.kind}${entry.tag ? ` <${entry.tag}>` : ''}`, ` ${entry.message}`, ` on ${where.slice(0, 5).join(', ')}${where.length > 5 ? `, and ${where.length - 5} more` : ''}`);
|
|
42
|
+
}
|
|
29
43
|
return { code: failing.length > 0 ? 1 : 0, summary: lines.join('\n') };
|
|
30
44
|
};
|
package/dist/config.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { type Rules } from './rules.js';
|
|
|
4
4
|
export interface Config {
|
|
5
5
|
/** The lowest level each category prints at in the console, or `off`. */
|
|
6
6
|
console?: Partial<Configuration['console']>;
|
|
7
|
+
/** What `@fusion.dev/helix-core` does itself: whether it prints its runtime warnings. */
|
|
8
|
+
core?: Partial<Configuration['core']>;
|
|
7
9
|
/** How much detail goes with what is sent. */
|
|
8
10
|
detail?: Partial<Configuration['detail']>;
|
|
9
11
|
/** Where sending happens. Production takes `{ sample }`, from 0 to 1. */
|
|
@@ -33,8 +35,13 @@ export declare const DEFAULTS: {
|
|
|
33
35
|
readonly console: {
|
|
34
36
|
readonly coverage: 'info';
|
|
35
37
|
readonly findings: 'info';
|
|
38
|
+
readonly performance: 'off';
|
|
39
|
+
readonly runtime: 'warn';
|
|
36
40
|
readonly usage: 'debug';
|
|
37
41
|
};
|
|
42
|
+
readonly core: {
|
|
43
|
+
readonly warnings: true;
|
|
44
|
+
};
|
|
38
45
|
readonly detail: {
|
|
39
46
|
readonly evidence: 'full';
|
|
40
47
|
readonly hookValues: true;
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,14 @@ import { nearest } from './manifest.js';
|
|
|
2
2
|
import { CATEGORIES } from './report.js';
|
|
3
3
|
import { checkRules } from './rules.js';
|
|
4
4
|
export const DEFAULTS = {
|
|
5
|
-
console: {
|
|
5
|
+
console: {
|
|
6
|
+
coverage: 'info',
|
|
7
|
+
findings: 'info',
|
|
8
|
+
performance: 'off',
|
|
9
|
+
runtime: 'warn',
|
|
10
|
+
usage: 'debug',
|
|
11
|
+
},
|
|
12
|
+
core: { warnings: true },
|
|
6
13
|
detail: { evidence: 'full', hookValues: true, pages: 'patterns' },
|
|
7
14
|
environments: { ci: true, development: true, production: false },
|
|
8
15
|
send: {
|
|
@@ -32,8 +39,11 @@ const ALLOWED = {
|
|
|
32
39
|
console: {
|
|
33
40
|
coverage: ['info', 'off'],
|
|
34
41
|
findings: ['error', 'warn', 'info', 'off'],
|
|
42
|
+
performance: ['info', 'off'],
|
|
43
|
+
runtime: ['warn', 'off'],
|
|
35
44
|
usage: ['debug', 'off'],
|
|
36
45
|
},
|
|
46
|
+
core: { warnings: [true, false] },
|
|
37
47
|
detail: {
|
|
38
48
|
evidence: ['full', 'selector', 'none'],
|
|
39
49
|
hookValues: [true, false],
|
|
@@ -123,6 +133,7 @@ export const resolveConfig = (file, call) => {
|
|
|
123
133
|
return {
|
|
124
134
|
configuration: {
|
|
125
135
|
console: merged('console', { ...DEFAULTS.console }),
|
|
136
|
+
core: merged('core', { ...DEFAULTS.core }),
|
|
126
137
|
detail: merged('detail', { ...DEFAULTS.detail }),
|
|
127
138
|
environments: merged('environments', { ...DEFAULTS.environments }),
|
|
128
139
|
sinks: merged('sinks', { ...DEFAULTS.sinks }),
|
package/dist/console.d.ts
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
* | A finding at `error` | `console.error` | The CSS does nothing where it was written |
|
|
10
10
|
* | A finding at `warn` | `console.warn` | Worth fixing, and the page works |
|
|
11
11
|
* | A finding at `info` | `console.info` | Advice |
|
|
12
|
+
* | A runtime event core left to diagnostics | `console.warn`, or `console.error` for a failure | The library was used wrong as it ran |
|
|
12
13
|
* | Unreadable stylesheets | `console.warn` | The run could not see part of the page |
|
|
13
14
|
* | Coverage, overrides, a clean run | `console.info` | What the run looked at |
|
|
14
15
|
* | Usage | `console.debug` | For whoever studies usage, hidden at the default level |
|
|
16
|
+
* | Performance, where asked for | `console.info` | What the components cost, by tag |
|
|
15
17
|
*
|
|
16
18
|
* **All of it in one group**, headed by the counts, so a page with other logs
|
|
17
19
|
* keeps a run together and a clean run collapses to one line.
|
package/dist/console.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { shownRuntime } from './runtime.js';
|
|
1
2
|
const entry = (finding) => {
|
|
2
3
|
const by = finding.decidedBy === 'shipped' ? '' : ` (${finding.severity}, set by the ${finding.decidedBy})`;
|
|
3
4
|
const lines = [`${finding.kind}${by}: ${finding.subject}`, finding.message];
|
|
@@ -16,16 +17,18 @@ export const report = (result, to = console, { shown, } = {}) => {
|
|
|
16
17
|
const warnings = bySeverity('warn');
|
|
17
18
|
const off = configuration.silenced.reduce((total, one) => total + one.count, 0);
|
|
18
19
|
const missed = coverage.sheets - coverage.read;
|
|
20
|
+
const runtime = shownRuntime(result.runtime);
|
|
19
21
|
const counts = [
|
|
20
22
|
counted(errors, 'error'),
|
|
21
23
|
counted(warnings, 'warning'),
|
|
22
24
|
`${bySeverity('info')} info`,
|
|
25
|
+
...(runtime.length > 0 ? [`${runtime.length} runtime`] : []),
|
|
23
26
|
...(off > 0 ? [`${off} turned off`] : []),
|
|
24
27
|
];
|
|
25
|
-
const heading = findings.length === 0 && off === 0
|
|
28
|
+
const heading = findings.length === 0 && off === 0 && runtime.length === 0
|
|
26
29
|
? 'helix diagnostics: no findings'
|
|
27
30
|
: `helix diagnostics: ${counts.join(', ')}`;
|
|
28
|
-
if (errors + warnings > 0 || missed > 0) {
|
|
31
|
+
if (errors + warnings + runtime.length > 0 || missed > 0) {
|
|
29
32
|
to.group(heading);
|
|
30
33
|
}
|
|
31
34
|
else {
|
|
@@ -64,6 +67,34 @@ export const report = (result, to = console, { shown, } = {}) => {
|
|
|
64
67
|
to.info(`${counted(before, 'finding')} printed before, on this page, and not again`);
|
|
65
68
|
}
|
|
66
69
|
}
|
|
70
|
+
if (levels.runtime !== 'off') {
|
|
71
|
+
for (const one of runtime) {
|
|
72
|
+
if (one.printed || shown?.has(one.fingerprint)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
shown?.add(one.fingerprint);
|
|
76
|
+
to[one.level](`${one.kind}${one.tag ? ` <${one.tag}>` : ''}: ${one.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (levels.performance !== 'off' && result.performance) {
|
|
80
|
+
const { measured, tags } = result.performance;
|
|
81
|
+
const rows = Object.entries(tags)
|
|
82
|
+
.sort(([, a], [, b]) => b.render.p95 - a.render.p95)
|
|
83
|
+
.slice(0, 10);
|
|
84
|
+
const lines = rows.map(([tag, one]) => `${tag}: ${one.render.p50} ms median, ${one.render.p95} ms p95, ${one.render.max} ms slowest, of ${counted(one.render.count, 'first update')}` +
|
|
85
|
+
(measured.layoutShift && one.shift > 0 ? `; ${one.shift} layout shift` : '') +
|
|
86
|
+
(measured.longTasks && one.longTasks > 0 ? `; ${counted(one.longTasks, 'long task')}` : ''));
|
|
87
|
+
const key = `performance ${result.run.page ?? ''} ${lines.join('|')}`;
|
|
88
|
+
if (rows.length > 0 && !shown?.has(key)) {
|
|
89
|
+
shown?.add(key);
|
|
90
|
+
to.info([
|
|
91
|
+
`performance: ${counted(Object.keys(tags).length, 'tag')} measured, slowest first` +
|
|
92
|
+
(measured.layoutShift ? '' : '. Layout shift is not measured in this browser') +
|
|
93
|
+
(measured.longTasks ? '' : '. Long tasks are not measured in this browser'),
|
|
94
|
+
...lines,
|
|
95
|
+
].join('\n'));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
67
98
|
if (levels.usage !== 'off') {
|
|
68
99
|
const { counted: how, hooks, parts, tags, unlisted } = result.usage;
|
|
69
100
|
const sum = (counts) => Object.values(counts).reduce((total, one) => total + Object.keys(one).length, 0);
|
package/dist/diagnose.js
CHANGED
|
@@ -5,6 +5,7 @@ import { identify } from './identity.js';
|
|
|
5
5
|
import { index, lintFor, versionsOf } from './manifest.js';
|
|
6
6
|
import { pageOf } from './page.js';
|
|
7
7
|
import { applyRules, overridesOf } from './rules.js';
|
|
8
|
+
import { logged, tally } from './runtime.js';
|
|
8
9
|
import { listed, loadedVersions, styled, walk } from './usage.js';
|
|
9
10
|
import { VERSION } from './version.js';
|
|
10
11
|
const runId = () => globalThis.crypto?.randomUUID?.() ??
|
|
@@ -24,6 +25,20 @@ export const diagnose = (options) => {
|
|
|
24
25
|
const lints = tiers.map((tier) => ({ lint: lintFor(tier) }));
|
|
25
26
|
const tags = index(lints);
|
|
26
27
|
const counted = watcher ? watcher.counts() : walk(document_, walked);
|
|
28
|
+
const runtime = watcher
|
|
29
|
+
? watcher.runtime()
|
|
30
|
+
: (() => {
|
|
31
|
+
const kept = tally();
|
|
32
|
+
for (const event of logged()) {
|
|
33
|
+
kept.add(event, true);
|
|
34
|
+
}
|
|
35
|
+
return kept.entries();
|
|
36
|
+
})();
|
|
37
|
+
const reported = runtime
|
|
38
|
+
.filter((one) => one.kind === 'duplicate-registration' && one.tag !== undefined)
|
|
39
|
+
.map((one) => one.tag);
|
|
40
|
+
const twice = duplicates ?? (reported.length > 0 ? () => reported : undefined);
|
|
41
|
+
const measured = watcher?.performance();
|
|
27
42
|
const seen = loadedVersions();
|
|
28
43
|
const manifests = versionsOf(lints);
|
|
29
44
|
const byVersion = versionSkew(manifests, seen);
|
|
@@ -34,7 +49,7 @@ export const diagnose = (options) => {
|
|
|
34
49
|
: applyRules([
|
|
35
50
|
...analyze(located, tags, document_, answered, used, readers),
|
|
36
51
|
...markup(tags, document_, walked),
|
|
37
|
-
...registrations(document_,
|
|
52
|
+
...registrations(document_, twice, walked),
|
|
38
53
|
].map((one) => identify(one, tags)), file?.rules, call.rules);
|
|
39
54
|
const path = page ?? globalThis.location?.pathname;
|
|
40
55
|
const shownPage = path === undefined || detail.pages === 'none'
|
|
@@ -59,6 +74,8 @@ export const diagnose = (options) => {
|
|
|
59
74
|
...(environment === undefined ? {} : { environment }),
|
|
60
75
|
...(shownPage === undefined ? {} : { page: shownPage }),
|
|
61
76
|
},
|
|
77
|
+
...(measured === undefined ? {} : { performance: measured }),
|
|
78
|
+
runtime,
|
|
62
79
|
schema: 1,
|
|
63
80
|
usage: {
|
|
64
81
|
...listed(counted, tags),
|
package/dist/identity.d.ts
CHANGED
|
@@ -30,5 +30,11 @@ export declare const normalize: (subject: string) => string;
|
|
|
30
30
|
* seen twice, which is how a reader thinks of it.
|
|
31
31
|
*/
|
|
32
32
|
export declare const fingerprintOf: (finding: Pick<Raw, 'kind' | 'subject' | 'tag'>) => string;
|
|
33
|
+
/**
|
|
34
|
+
* A runtime event's identity across runs: its kind, its tag, and its sentence
|
|
35
|
+
* with each run of digits read as one, so a count in the sentence is not part
|
|
36
|
+
* of what the event is.
|
|
37
|
+
*/
|
|
38
|
+
export declare const runtimeFingerprint: (kind: string, tag: string | undefined, message: string) => string;
|
|
33
39
|
/** A finding with its category, owner, family, and fingerprint. */
|
|
34
40
|
export declare const identify: (finding: Raw, tags: Map<string, Published>) => Identified;
|
package/dist/identity.js
CHANGED
|
@@ -37,6 +37,7 @@ const hash = (text) => {
|
|
|
37
37
|
return (4294967296 * (2097151 & two) + (one >>> 0)).toString(16).padStart(14, '0');
|
|
38
38
|
};
|
|
39
39
|
export const fingerprintOf = (finding) => hash(`${finding.kind}\u0000${finding.tag}\u0000${normalize(finding.subject)}`);
|
|
40
|
+
export const runtimeFingerprint = (kind, tag, message) => hash(`runtime\u0000${kind}\u0000${tag ?? ''}\u0000${message.replace(/\d[\d,.]*/g, '#')}`);
|
|
40
41
|
export const identify = (finding, tags) => ({
|
|
41
42
|
...finding,
|
|
42
43
|
category: CATEGORY[finding.kind],
|
package/dist/init.js
CHANGED
|
@@ -2,6 +2,7 @@ import { checkConfig, LOCAL, resolveConfig } from './config.js';
|
|
|
2
2
|
import { report as print } from './console.js';
|
|
3
3
|
import { diagnose } from './diagnose.js';
|
|
4
4
|
import { overlayOn } from './overlay.js';
|
|
5
|
+
import { shownRuntime } from './runtime.js';
|
|
5
6
|
import { schedule } from './schedule.js';
|
|
6
7
|
import { loadedVersions, watch } from './usage.js';
|
|
7
8
|
export const INSTANCE = Symbol.for('fusion.dev/helix/diagnostics');
|
|
@@ -53,14 +54,15 @@ export const initDiagnostics = (options = {}) => {
|
|
|
53
54
|
};
|
|
54
55
|
const start = (options, page) => {
|
|
55
56
|
const { cdn = CDN, duplicates, environment, file, onReport, page: route, tiers, ...call } = options;
|
|
56
|
-
let
|
|
57
|
+
let resolved;
|
|
57
58
|
try {
|
|
58
|
-
|
|
59
|
+
resolved = resolveConfig(file, call);
|
|
59
60
|
}
|
|
60
61
|
catch (error) {
|
|
61
62
|
once(`settings ${messageOf(error)}`, () => console.error(`helix diagnostics: not started. ${messageOf(error)}`));
|
|
62
63
|
return inert();
|
|
63
64
|
}
|
|
65
|
+
const { configuration } = resolved;
|
|
64
66
|
const host = globalThis.location?.hostname ?? '';
|
|
65
67
|
if (environment === undefined && !LOCAL.test(host)) {
|
|
66
68
|
once(`host ${host}`, () => console.info(`helix diagnostics: not running on ${host || 'this page'}, which is no local host, with no environment declared. Pass \`environment\` to run here.`));
|
|
@@ -78,11 +80,13 @@ const start = (options, page) => {
|
|
|
78
80
|
duplicates,
|
|
79
81
|
environment,
|
|
80
82
|
file,
|
|
83
|
+
measure: resolved.send.performance.on || configuration.console.performance !== 'off',
|
|
81
84
|
onReport,
|
|
82
85
|
page,
|
|
83
86
|
route,
|
|
84
87
|
sinks: configuration.sinks,
|
|
85
88
|
tiers,
|
|
89
|
+
warnings: configuration.core.warnings,
|
|
86
90
|
});
|
|
87
91
|
};
|
|
88
92
|
const running = (given) => {
|
|
@@ -90,6 +94,7 @@ const running = (given) => {
|
|
|
90
94
|
const production = environment === 'production';
|
|
91
95
|
const development = environment === undefined || environment === 'development';
|
|
92
96
|
let file = given.file;
|
|
97
|
+
let warnings = given.warnings;
|
|
93
98
|
let overlay;
|
|
94
99
|
const relay = production ? undefined : sinks.relay === true ? RELAY : sinks.relay || undefined;
|
|
95
100
|
let relayed = false;
|
|
@@ -164,7 +169,7 @@ const running = (given) => {
|
|
|
164
169
|
deliver('the helix-diagnostics event', () => page.dispatchEvent(new CustomEvent('helix-diagnostics', { detail: run })));
|
|
165
170
|
if (sinks.overlay && development) {
|
|
166
171
|
deliver('the overlay', () => {
|
|
167
|
-
if (overlay || run.findings.length > 0) {
|
|
172
|
+
if (overlay || run.findings.length > 0 || shownRuntime(run.runtime).length > 0) {
|
|
168
173
|
overlay ??= overlayOn(page);
|
|
169
174
|
overlay.show(run);
|
|
170
175
|
}
|
|
@@ -251,6 +256,7 @@ const running = (given) => {
|
|
|
251
256
|
try {
|
|
252
257
|
checkConfig(served, 'file');
|
|
253
258
|
file = served;
|
|
259
|
+
warnings = resolveConfig(file, call).configuration.core.warnings;
|
|
254
260
|
}
|
|
255
261
|
catch (error) {
|
|
256
262
|
once(`relay settings ${messageOf(error)}`, () => console.error(`helix diagnostics: the settings the relay served were not used. ${messageOf(error)}`));
|
|
@@ -262,7 +268,12 @@ const running = (given) => {
|
|
|
262
268
|
.catch((error) => once('no relay', () => console.warn(`helix diagnostics: no relay at ${relay} (${messageOf(error)}). Start it with \`helix diagnostics\`, or turn \`sinks.relay\` off.`)));
|
|
263
269
|
}
|
|
264
270
|
const plan = schedule(runNow);
|
|
265
|
-
const watcher = watch(page, {
|
|
271
|
+
const watcher = watch(page, {
|
|
272
|
+
onRender: () => plan.poke(),
|
|
273
|
+
onRuntime: () => plan.poke(),
|
|
274
|
+
performance: given.measure,
|
|
275
|
+
quiet: () => !warnings,
|
|
276
|
+
});
|
|
266
277
|
const poke = () => plan.poke();
|
|
267
278
|
const observer = typeof MutationObserver === 'function'
|
|
268
279
|
? new MutationObserver((records) => {
|
package/dist/overlay.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { matchable } from './analyze.js';
|
|
2
2
|
import { OVERLAY } from './page.js';
|
|
3
|
+
import { shownRuntime } from './runtime.js';
|
|
3
4
|
const STYLE = `
|
|
4
5
|
:host {
|
|
5
6
|
all: initial;
|
|
@@ -228,30 +229,40 @@ export const overlayOn = (page) => {
|
|
|
228
229
|
badge.focus();
|
|
229
230
|
}
|
|
230
231
|
});
|
|
231
|
-
const point = (
|
|
232
|
-
const target = finding ? locate(finding, page) : null;
|
|
232
|
+
const point = (target, severity) => {
|
|
233
233
|
if (!target) {
|
|
234
234
|
outline.hidden = true;
|
|
235
235
|
return;
|
|
236
236
|
}
|
|
237
237
|
const box = target.getBoundingClientRect();
|
|
238
|
-
outline.style.borderColor = `var(--${
|
|
238
|
+
outline.style.borderColor = `var(--${severity})`;
|
|
239
239
|
outline.style.insetBlockStart = `${box.top - 3}px`;
|
|
240
240
|
outline.style.insetInlineStart = `${box.left - 3}px`;
|
|
241
241
|
outline.style.blockSize = `${box.height + 2}px`;
|
|
242
242
|
outline.style.inlineSize = `${box.width + 2}px`;
|
|
243
243
|
outline.hidden = false;
|
|
244
244
|
};
|
|
245
|
+
const follow = (item, target, severity) => {
|
|
246
|
+
item.addEventListener('mouseenter', () => point(target(), severity));
|
|
247
|
+
item.addEventListener('focus', () => point(target(), severity));
|
|
248
|
+
item.addEventListener('mouseleave', () => point(null));
|
|
249
|
+
item.addEventListener('blur', () => point(null));
|
|
250
|
+
};
|
|
245
251
|
const show = (run) => {
|
|
246
252
|
const findings = run.findings;
|
|
253
|
+
const runtime = shownRuntime(run.runtime);
|
|
247
254
|
const of = (severity) => findings.filter((one) => one.severity === severity).length;
|
|
248
|
-
const
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
+
const failed = runtime.some((one) => one.level === 'error');
|
|
256
|
+
const worst = of('error') > 0 || failed ? 'error' : of('warn') > 0 || runtime.length > 0 ? 'warn' : 'info';
|
|
257
|
+
const counts = [
|
|
258
|
+
...['error', 'warn', 'info']
|
|
259
|
+
.filter((severity) => of(severity) > 0)
|
|
260
|
+
.map((severity) => counted(of(severity), severity)),
|
|
261
|
+
...(runtime.length > 0 ? [`${runtime.length} runtime`] : []),
|
|
262
|
+
].join(', ');
|
|
263
|
+
const total = findings.length + runtime.length;
|
|
264
|
+
badge.hidden = total === 0;
|
|
265
|
+
if (total === 0) {
|
|
255
266
|
open(false);
|
|
256
267
|
}
|
|
257
268
|
dot.style.background = `var(--${worst})`;
|
|
@@ -267,10 +278,21 @@ export const overlayOn = (page) => {
|
|
|
267
278
|
if (finding.origin) {
|
|
268
279
|
item.append(make('span', 'where', `in ${finding.origin.sheet}`), make('pre', undefined, finding.origin.cssText));
|
|
269
280
|
}
|
|
270
|
-
item
|
|
271
|
-
item
|
|
272
|
-
|
|
273
|
-
item
|
|
281
|
+
follow(item, () => locate(finding, page), finding.severity);
|
|
282
|
+
return item;
|
|
283
|
+
}), ...runtime.map((one) => {
|
|
284
|
+
const item = make('li');
|
|
285
|
+
item.tabIndex = 0;
|
|
286
|
+
const head = make('span', 'kind');
|
|
287
|
+
head.append(make('span', one.level, `${one.level} `), document.createTextNode(one.kind));
|
|
288
|
+
item.append(head);
|
|
289
|
+
if (one.tag) {
|
|
290
|
+
item.append(make('code', 'subject', `<${one.tag}>`));
|
|
291
|
+
}
|
|
292
|
+
item.append(worded(one.message), make('span', 'where', one.count === 1
|
|
293
|
+
? 'Reported by Helix as the page ran'
|
|
294
|
+
: `Reported by Helix ${one.count} times as the page ran`));
|
|
295
|
+
follow(item, () => (one.tag ? page.querySelector(one.tag) : null), one.level);
|
|
274
296
|
return item;
|
|
275
297
|
}));
|
|
276
298
|
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PerformanceReport } from './report.js';
|
|
2
|
+
/** The key `@fusion.dev/helix-core` hands each first update's timing to. */
|
|
3
|
+
export declare const RENDER_SLOT: unique symbol;
|
|
4
|
+
/** A measure as the page holds it, until a run reads it. */
|
|
5
|
+
export interface Measurer {
|
|
6
|
+
/** The measures so far. A copy: measuring goes on after it is read. */
|
|
7
|
+
results(): PerformanceReport;
|
|
8
|
+
/** Stops measuring, and gives the slot back to whatever held it before. */
|
|
9
|
+
stop(): void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The tag of the Helix element a node belongs to: the node itself, or the
|
|
13
|
+
* nearest Helix element above it, through shadow roots.
|
|
14
|
+
*/
|
|
15
|
+
export declare const helixOf: (node: Node | null | undefined) => string | undefined;
|
|
16
|
+
/** The value at a fraction of the way through sorted numbers, by nearest rank. */
|
|
17
|
+
export declare const percentile: (sorted: readonly number[], fraction: number) => number;
|
|
18
|
+
/** A layout-shift entry's part, as this reads it. */
|
|
19
|
+
interface Shift {
|
|
20
|
+
hadRecentInput?: boolean;
|
|
21
|
+
sources?: ReadonlyArray<{
|
|
22
|
+
node?: Node | null | undefined;
|
|
23
|
+
}>;
|
|
24
|
+
value: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Shares one shift's score out among the Helix elements its sources belong
|
|
28
|
+
* to, an equal share each. A shift a reader caused, by typing or pressing, is
|
|
29
|
+
* no fault of the page's, which is how the browser scores it too.
|
|
30
|
+
*/
|
|
31
|
+
export declare const shares: (shift: Shift) => Map<string, number>;
|
|
32
|
+
/** Starts measuring, from now on, and the layout shift and long tasks the browser kept from before. */
|
|
33
|
+
export declare const measure: () => Measurer;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { OVERLAY } from './page.js';
|
|
2
|
+
export const RENDER_SLOT = Symbol.for('fusion.dev/helix/render');
|
|
3
|
+
const KEPT = 1000;
|
|
4
|
+
export const helixOf = (node) => {
|
|
5
|
+
let at = node;
|
|
6
|
+
while (at) {
|
|
7
|
+
if (at.nodeType === 1) {
|
|
8
|
+
const tag = at.localName;
|
|
9
|
+
if (tag.startsWith('helix-') && tag !== OVERLAY) {
|
|
10
|
+
return tag;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
at = at.parentNode ?? at.host ?? null;
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
};
|
|
17
|
+
export const percentile = (sorted, fraction) => sorted.length === 0
|
|
18
|
+
? 0
|
|
19
|
+
: (sorted[Math.min(sorted.length - 1, Math.ceil(fraction * sorted.length) - 1)] ?? 0);
|
|
20
|
+
const tenth = (value) => Math.round(value * 10) / 10;
|
|
21
|
+
const thousandth = (value) => Math.round(value * 1000) / 1000;
|
|
22
|
+
export const shares = (shift) => {
|
|
23
|
+
const out = new Map();
|
|
24
|
+
const sources = shift.sources ?? [];
|
|
25
|
+
if (shift.hadRecentInput || sources.length === 0) {
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
const each = shift.value / sources.length;
|
|
29
|
+
for (const source of sources) {
|
|
30
|
+
const tag = helixOf(source.node);
|
|
31
|
+
if (tag) {
|
|
32
|
+
out.set(tag, (out.get(tag) ?? 0) + each);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
};
|
|
37
|
+
const supports = (type) => typeof PerformanceObserver === 'function' &&
|
|
38
|
+
(PerformanceObserver.supportedEntryTypes ?? []).includes(type);
|
|
39
|
+
export const measure = () => {
|
|
40
|
+
const gathered = new Map();
|
|
41
|
+
const of = (tag) => {
|
|
42
|
+
let one = gathered.get(tag);
|
|
43
|
+
if (!one) {
|
|
44
|
+
one = { durations: [], shift: 0, spans: [] };
|
|
45
|
+
gathered.set(tag, one);
|
|
46
|
+
}
|
|
47
|
+
return one;
|
|
48
|
+
};
|
|
49
|
+
const long = [];
|
|
50
|
+
const store = globalThis;
|
|
51
|
+
const before = store[RENDER_SLOT];
|
|
52
|
+
let live = true;
|
|
53
|
+
const listener = (element, timing) => {
|
|
54
|
+
if (live && element?.localName !== OVERLAY) {
|
|
55
|
+
const one = of(element.localName);
|
|
56
|
+
one.durations.push(timing.duration);
|
|
57
|
+
if (one.spans.length < KEPT) {
|
|
58
|
+
one.spans.push({ end: timing.start + timing.duration, start: timing.start });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (typeof before === 'function') {
|
|
62
|
+
before(element, timing);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
store[RENDER_SLOT] = listener;
|
|
66
|
+
const observers = [];
|
|
67
|
+
const observe = (type, take) => {
|
|
68
|
+
if (!supports(type)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const observer = new PerformanceObserver((list) => {
|
|
73
|
+
for (const entry of list.getEntries()) {
|
|
74
|
+
take(entry);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
observer.observe({ buffered: true, type });
|
|
78
|
+
observers.push(observer);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const layoutShift = observe('layout-shift', (entry) => {
|
|
86
|
+
for (const [tag, share] of shares(entry)) {
|
|
87
|
+
of(tag).shift += share;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
const longTasks = observe('longtask', (entry) => {
|
|
91
|
+
long.push({ end: entry.startTime + entry.duration, start: entry.startTime });
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
results: () => {
|
|
95
|
+
const tags = {};
|
|
96
|
+
for (const [tag, one] of [...gathered].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
97
|
+
const sorted = [...one.durations].sort((a, b) => a - b);
|
|
98
|
+
tags[tag] = {
|
|
99
|
+
longTasks: long.filter((task) => one.spans.some((span) => span.start < task.end && span.end > task.start)).length,
|
|
100
|
+
render: {
|
|
101
|
+
count: sorted.length,
|
|
102
|
+
max: tenth(sorted.at(-1) ?? 0),
|
|
103
|
+
p50: tenth(percentile(sorted, 0.5)),
|
|
104
|
+
p95: tenth(percentile(sorted, 0.95)),
|
|
105
|
+
},
|
|
106
|
+
shift: thousandth(one.shift),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return { measured: { layoutShift, longTasks }, tags };
|
|
110
|
+
},
|
|
111
|
+
stop: () => {
|
|
112
|
+
live = false;
|
|
113
|
+
for (const observer of observers) {
|
|
114
|
+
observer.disconnect();
|
|
115
|
+
}
|
|
116
|
+
if (store[RENDER_SLOT] === listener) {
|
|
117
|
+
store[RENDER_SLOT] = before;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
};
|
package/dist/report.d.ts
CHANGED
|
@@ -109,6 +109,63 @@ export type Identified = Omit<Finding, 'decidedBy' | 'severity'>;
|
|
|
109
109
|
export declare const CATEGORIES: readonly ['assist', 'configuration', 'coverage', 'findings', 'performance', 'runtime', 'usage'];
|
|
110
110
|
/** A kind of telemetry. */
|
|
111
111
|
export type Category = (typeof CATEGORIES)[number];
|
|
112
|
+
/**
|
|
113
|
+
* What the components cost on a page, by tag, where a project asked for it.
|
|
114
|
+
*/
|
|
115
|
+
export interface PerformanceReport {
|
|
116
|
+
/**
|
|
117
|
+
* What this browser could measure. Layout shift and long tasks are
|
|
118
|
+
* Chromium's, so elsewhere their numbers are absent, never zero.
|
|
119
|
+
*/
|
|
120
|
+
measured: {
|
|
121
|
+
layoutShift: boolean;
|
|
122
|
+
longTasks: boolean;
|
|
123
|
+
};
|
|
124
|
+
/** Each tag rendered on the page. */
|
|
125
|
+
tags: Record<string, {
|
|
126
|
+
/** How many long tasks, of 50 ms or more, one of its elements' first updates ran in. */
|
|
127
|
+
longTasks: number;
|
|
128
|
+
/**
|
|
129
|
+
* Its elements' first updates, in milliseconds: how many, the median,
|
|
130
|
+
* the 95th percentile, and the slowest. Each is the element's own
|
|
131
|
+
* update, from `willUpdate` to `updated`, so time spent waiting behind
|
|
132
|
+
* other elements is not counted.
|
|
133
|
+
*/
|
|
134
|
+
render: {
|
|
135
|
+
count: number;
|
|
136
|
+
max: number;
|
|
137
|
+
p50: number;
|
|
138
|
+
p95: number;
|
|
139
|
+
};
|
|
140
|
+
/** The layout shift its elements moved, summed as the browser scores it. */
|
|
141
|
+
shift: number;
|
|
142
|
+
}>;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* A runtime event as a run holds it: what `@fusion.dev/helix-core` reported
|
|
146
|
+
* as the library ran, such as a selectable table with no `row-key`, one entry
|
|
147
|
+
* per distinct event on the page.
|
|
148
|
+
*/
|
|
149
|
+
export interface RuntimeEntry {
|
|
150
|
+
/** How many times it was reported on this page. */
|
|
151
|
+
count: number;
|
|
152
|
+
/**
|
|
153
|
+
* Its identity across runs: the kind, the tag, and the sentence with its
|
|
154
|
+
* numbers left out, so a table drawing 3,000 rows and one drawing 3,200 are
|
|
155
|
+
* one event.
|
|
156
|
+
*/
|
|
157
|
+
fingerprint: string;
|
|
158
|
+
/** What went wrong, as core names it, stable across versions. */
|
|
159
|
+
kind: string;
|
|
160
|
+
/** `error` for a failure, `warn` for a mistake in how the library is used. */
|
|
161
|
+
level: 'error' | 'warn';
|
|
162
|
+
/** The sentence core prints, which says what to do. */
|
|
163
|
+
message: string;
|
|
164
|
+
/** Whether core printed it as it happened; where it did not, the console sink does. */
|
|
165
|
+
printed: boolean;
|
|
166
|
+
/** The tag of the element it happened on, where there is one. */
|
|
167
|
+
tag?: string;
|
|
168
|
+
}
|
|
112
169
|
/** Where a run was declared to happen. Undeclared is never sent. */
|
|
113
170
|
export type Environment = 'ci' | 'development' | 'production';
|
|
114
171
|
/** A sheet the collector could not read, and the reason. */
|
|
@@ -188,8 +245,20 @@ export interface Configuration {
|
|
|
188
245
|
console: {
|
|
189
246
|
coverage: 'info' | 'off';
|
|
190
247
|
findings: Severity | 'off';
|
|
248
|
+
/** What the components cost, by tag, which is measured where this is on. */
|
|
249
|
+
performance: 'info' | 'off';
|
|
250
|
+
/** The runtime events core left to diagnostics to print. */
|
|
251
|
+
runtime: 'warn' | 'off';
|
|
191
252
|
usage: 'debug' | 'off';
|
|
192
253
|
};
|
|
254
|
+
/** What `@fusion.dev/helix-core` does itself. */
|
|
255
|
+
core: {
|
|
256
|
+
/**
|
|
257
|
+
* Whether core prints each runtime warning as it happens. Off, diagnostics
|
|
258
|
+
* prints them in its own group, at the `console.runtime` level.
|
|
259
|
+
*/
|
|
260
|
+
warnings: boolean;
|
|
261
|
+
};
|
|
193
262
|
/** How much detail goes with what is sent. */
|
|
194
263
|
detail: {
|
|
195
264
|
evidence: 'full' | 'none' | 'selector';
|
|
@@ -242,6 +311,16 @@ export interface Report {
|
|
|
242
311
|
configuration: Configuration;
|
|
243
312
|
coverage: Coverage;
|
|
244
313
|
findings: Finding[];
|
|
314
|
+
/**
|
|
315
|
+
* What the components cost, by tag. Present only where the project asked,
|
|
316
|
+
* with `console.performance` or `send.performance`.
|
|
317
|
+
*/
|
|
318
|
+
performance?: PerformanceReport;
|
|
319
|
+
/**
|
|
320
|
+
* What the library reported going wrong as it ran on this page, from its
|
|
321
|
+
* first load: one entry per distinct event, counted.
|
|
322
|
+
*/
|
|
323
|
+
runtime: RuntimeEntry[];
|
|
245
324
|
/**
|
|
246
325
|
* The project's own name and labels, as its configuration gives them. The
|
|
247
326
|
* key is never written into a report.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { RuntimeEntry } from './report.js';
|
|
2
|
+
/** The key core reports each event to. */
|
|
3
|
+
export declare const RUNTIME_SLOT: unique symbol;
|
|
4
|
+
/** The key core keeps the events it reported under, the first two hundred. */
|
|
5
|
+
export declare const RUNTIME_LOG: unique symbol;
|
|
6
|
+
/**
|
|
7
|
+
* The kinds a finding already reports, which the console and the overlay leave
|
|
8
|
+
* out: two copies of Helix on a page is the `duplicate-registration` finding.
|
|
9
|
+
*/
|
|
10
|
+
export declare const AS_FINDINGS: ReadonlySet<string>;
|
|
11
|
+
/** An event as core reports it, read without importing core. */
|
|
12
|
+
export interface Heard {
|
|
13
|
+
detail?: unknown;
|
|
14
|
+
kind: string;
|
|
15
|
+
level?: 'error' | 'warn';
|
|
16
|
+
message: string;
|
|
17
|
+
tag?: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
/** The events core has kept so far. */
|
|
20
|
+
export declare const logged: () => Heard[];
|
|
21
|
+
/** One entry per distinct event, counted as each is heard. */
|
|
22
|
+
export interface Tally {
|
|
23
|
+
/** Counts one event; `printed` says whether core printed it. */
|
|
24
|
+
add(event: unknown, printed: boolean): void;
|
|
25
|
+
/** The entries so far. Copies: counting goes on after they are read. */
|
|
26
|
+
entries(): RuntimeEntry[];
|
|
27
|
+
}
|
|
28
|
+
/** Starts a count. */
|
|
29
|
+
export declare const tally: () => Tally;
|
|
30
|
+
/** The entries a reader is shown: those no finding reports already. */
|
|
31
|
+
export declare const shownRuntime: (entries: readonly RuntimeEntry[]) => RuntimeEntry[];
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { runtimeFingerprint } from './identity.js';
|
|
2
|
+
export const RUNTIME_SLOT = Symbol.for('fusion.dev/helix/runtime');
|
|
3
|
+
export const RUNTIME_LOG = Symbol.for('fusion.dev/helix/runtime-log');
|
|
4
|
+
export const AS_FINDINGS = new Set(['duplicate-registration']);
|
|
5
|
+
const isHeard = (value) => typeof value === 'object' &&
|
|
6
|
+
value !== null &&
|
|
7
|
+
typeof value.kind === 'string' &&
|
|
8
|
+
typeof value.message === 'string';
|
|
9
|
+
export const logged = () => {
|
|
10
|
+
const log = globalThis[RUNTIME_LOG];
|
|
11
|
+
return Array.isArray(log) ? log.filter(isHeard) : [];
|
|
12
|
+
};
|
|
13
|
+
export const tally = () => {
|
|
14
|
+
const entries = new Map();
|
|
15
|
+
return {
|
|
16
|
+
add(event, printed) {
|
|
17
|
+
if (!isHeard(event)) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const fingerprint = runtimeFingerprint(event.kind, event.tag, event.message);
|
|
21
|
+
const known = entries.get(fingerprint);
|
|
22
|
+
if (known) {
|
|
23
|
+
known.count += 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
entries.set(fingerprint, {
|
|
27
|
+
count: 1,
|
|
28
|
+
fingerprint,
|
|
29
|
+
kind: event.kind,
|
|
30
|
+
level: event.level === 'error' ? 'error' : 'warn',
|
|
31
|
+
message: event.message,
|
|
32
|
+
printed,
|
|
33
|
+
...(event.tag === undefined ? {} : { tag: event.tag }),
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
entries: () => [...entries.values()].map((one) => ({ ...one })),
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
export const shownRuntime = (entries) => entries.filter((one) => !AS_FINDINGS.has(one.kind));
|
package/dist/usage.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Answered, Located } from './collect.js';
|
|
2
2
|
import type { Published } from './manifest.js';
|
|
3
3
|
import { type Page } from './page.js';
|
|
4
|
-
import type { Usage } from './report.js';
|
|
4
|
+
import type { PerformanceReport, RuntimeEntry, Usage } from './report.js';
|
|
5
|
+
import { type Heard } from './runtime.js';
|
|
5
6
|
/** The element counts, before the stylesheets add theirs. */
|
|
6
7
|
type Counted = Pick<Usage, 'axes' | 'composed' | 'slots' | 'tags'>;
|
|
7
8
|
/**
|
|
@@ -23,7 +24,15 @@ export declare const listed: (counted: Counted, tags: Map<string, Published>) =>
|
|
|
23
24
|
export interface Watcher {
|
|
24
25
|
/** The counts so far. A copy: counting goes on after it is read. */
|
|
25
26
|
counts(): Counted;
|
|
26
|
-
/**
|
|
27
|
+
/** What the components cost so far, where the watcher was asked to measure. */
|
|
28
|
+
performance(): PerformanceReport | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* The runtime events reported on this page from its first load, one entry
|
|
31
|
+
* per distinct event: the ones core kept from before the watcher started,
|
|
32
|
+
* and the ones heard since.
|
|
33
|
+
*/
|
|
34
|
+
runtime(): RuntimeEntry[];
|
|
35
|
+
/** Stops counting, and gives the slots back to whatever held them before. */
|
|
27
36
|
stop(): void;
|
|
28
37
|
}
|
|
29
38
|
/**
|
|
@@ -41,13 +50,26 @@ export interface Watcher {
|
|
|
41
50
|
* Two watchers on one page both count: a second one keeps the first's
|
|
42
51
|
* listener and calls it too.
|
|
43
52
|
*/
|
|
44
|
-
export declare const watch: (from?: Document, { onRender, }?: {
|
|
53
|
+
export declare const watch: (from?: Document, { onRender, onRuntime, performance, quiet, }?: {
|
|
45
54
|
/**
|
|
46
55
|
* Called as each Helix element renders for the first time, after it is
|
|
47
56
|
* counted: how `initDiagnostics()` hears that the page changed when an
|
|
48
57
|
* element upgrades without adding anything to the DOM.
|
|
49
58
|
*/
|
|
50
59
|
onRender?: ((element: Element) => void) | undefined;
|
|
60
|
+
/** Called with each runtime event core reports, after it is counted. */
|
|
61
|
+
onRuntime?: ((event: Heard) => void) | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* Whether to measure what the components cost, from now on: each
|
|
64
|
+
* element's first update, and the layout shift and long tasks the browser
|
|
65
|
+
* reports.
|
|
66
|
+
*/
|
|
67
|
+
performance?: boolean | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* Whether core should leave each runtime event for diagnostics to print,
|
|
70
|
+
* asked as each arrives: `core.warnings: false`.
|
|
71
|
+
*/
|
|
72
|
+
quiet?: (() => boolean) | undefined;
|
|
51
73
|
}) => Watcher;
|
|
52
74
|
/**
|
|
53
75
|
* The parts and hooks a page's stylesheets reach for, where the manifest or
|
package/dist/usage.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { parts, targets } from './analyze.js';
|
|
2
2
|
import { OVERLAY, pageOf } from './page.js';
|
|
3
|
+
import { measure } from './performance.js';
|
|
4
|
+
import { logged, RUNTIME_SLOT, tally as tallyRuntime } from './runtime.js';
|
|
3
5
|
const SLOT = Symbol.for('fusion.dev/helix/usage');
|
|
4
6
|
const AXES = ['emphasis', 'material', 'separation', 'tone'];
|
|
5
7
|
const bump = (into, key, by = 1) => {
|
|
@@ -68,7 +70,7 @@ export const listed = (counted, tags) => {
|
|
|
68
70
|
unlisted,
|
|
69
71
|
};
|
|
70
72
|
};
|
|
71
|
-
export const watch = (from = document, { onRender, } = {}) => {
|
|
73
|
+
export const watch = (from = document, { onRender, onRuntime, performance, quiet, } = {}) => {
|
|
72
74
|
const counted = empty();
|
|
73
75
|
const seen = new WeakSet();
|
|
74
76
|
const see = (element) => {
|
|
@@ -93,13 +95,38 @@ export const watch = (from = document, { onRender, } = {}) => {
|
|
|
93
95
|
for (const element of pageOf(from).elements) {
|
|
94
96
|
see(element);
|
|
95
97
|
}
|
|
98
|
+
const heard = tallyRuntime();
|
|
99
|
+
for (const event of logged()) {
|
|
100
|
+
heard.add(event, true);
|
|
101
|
+
}
|
|
102
|
+
const beforeRuntime = store[RUNTIME_SLOT];
|
|
103
|
+
const hear = (event) => {
|
|
104
|
+
let silent = false;
|
|
105
|
+
if (live) {
|
|
106
|
+
silent = quiet?.() === true;
|
|
107
|
+
heard.add(event, !silent);
|
|
108
|
+
onRuntime?.(event);
|
|
109
|
+
}
|
|
110
|
+
if (typeof beforeRuntime === 'function' && beforeRuntime(event) === true) {
|
|
111
|
+
silent = true;
|
|
112
|
+
}
|
|
113
|
+
return silent;
|
|
114
|
+
};
|
|
115
|
+
store[RUNTIME_SLOT] = hear;
|
|
116
|
+
const measured = performance ? measure() : undefined;
|
|
96
117
|
return {
|
|
97
118
|
counts: () => structuredClone(counted),
|
|
119
|
+
performance: () => measured?.results(),
|
|
120
|
+
runtime: () => heard.entries(),
|
|
98
121
|
stop: () => {
|
|
99
122
|
live = false;
|
|
123
|
+
measured?.stop();
|
|
100
124
|
if (store[SLOT] === listener) {
|
|
101
125
|
store[SLOT] = before;
|
|
102
126
|
}
|
|
127
|
+
if (store[RUNTIME_SLOT] === hear) {
|
|
128
|
+
store[RUNTIME_SLOT] = beforeRuntime;
|
|
129
|
+
}
|
|
103
130
|
},
|
|
104
131
|
};
|
|
105
132
|
};
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.
|
|
1
|
+
export const VERSION = '0.140.0';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusion.dev/helix-diagnostics",
|
|
3
3
|
"description": "Finds the Helix mistakes that are valid CSS: dead parts, misnamed hooks, two copies of the library.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.140.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"bugs": "https://github.com/project-fusion/fusion/issues",
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@fusion.dev/helix-testing": "0.
|
|
36
|
+
"@fusion.dev/helix-testing": "0.140.0",
|
|
37
37
|
"@types/bun": "^1.3.0"
|
|
38
38
|
},
|
|
39
39
|
"homepage": "https://fusion.dev",
|