@describe-me/vitest 0.5.0-next.2 → 0.5.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 +4 -0
- package/dist/font-audit.d.ts +33 -0
- package/dist/font-audit.js +296 -0
- package/dist/font-family-key.d.ts +5 -0
- package/dist/font-family-key.js +14 -0
- package/dist/font-family-list.d.ts +6 -0
- package/dist/font-family-list.js +66 -0
- package/dist/font-shorthand-families.d.ts +6 -0
- package/dist/font-shorthand-families.js +177 -0
- package/dist/font-stylesheet-families.d.ts +6 -0
- package/dist/font-stylesheet-families.js +54 -0
- package/dist/font-usage.d.ts +26 -0
- package/dist/font-usage.js +163 -0
- package/dist/head-stylesheets.d.ts +8 -0
- package/dist/head-stylesheets.js +51 -0
- package/dist/print-diagnostics.d.ts +3 -2
- package/dist/print-diagnostics.js +13 -3
- package/dist/reporter.d.ts +9 -0
- package/dist/reporter.js +50 -9
- package/dist/snapshot-styles.d.ts +17 -0
- package/dist/snapshot-styles.js +65 -0
- package/dist/stable-test-id.d.ts +5 -0
- package/dist/stable-test-id.js +12 -0
- package/dist/stable-test-ids.d.ts +5 -0
- package/dist/stable-test-ids.js +13 -0
- package/dist/system-font-family.d.ts +5 -0
- package/dist/system-font-family.js +78 -0
- package/package.json +2 -2
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** A `var()` in the place of a family. */
|
|
2
|
+
export interface FontVarReference {
|
|
3
|
+
/** The custom property, such as `--font-body`. */
|
|
4
|
+
name: string;
|
|
5
|
+
/** The text after its first comma, when there is one. */
|
|
6
|
+
fallback?: string;
|
|
7
|
+
}
|
|
8
|
+
/** What a stylesheet or a `style` attribute says about fonts. */
|
|
9
|
+
export interface FontUsage {
|
|
10
|
+
/** The first family of each `font-family` and `font` declaration, unless it is a `var()`. */
|
|
11
|
+
families: string[];
|
|
12
|
+
/** Each `var()` in the place of a first family. */
|
|
13
|
+
varRefs: FontVarReference[];
|
|
14
|
+
/** Every custom property definition, in source order. */
|
|
15
|
+
variables: Map<string, string[]>;
|
|
16
|
+
/** The families of `@font-face` rules. */
|
|
17
|
+
declared: string[];
|
|
18
|
+
/** `@import` targets as written. */
|
|
19
|
+
imports: string[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The fonts that CSS text uses, defines as custom properties, declares with
|
|
23
|
+
* `@font-face` and imports. Reads a stylesheet or the bare text of a `style`
|
|
24
|
+
* attribute. Only `font-family`, `font` and custom properties count.
|
|
25
|
+
*/
|
|
26
|
+
export declare function fontUsage(css: string): FontUsage;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { cssReferences } from '@describe-me/core/css-references';
|
|
2
|
+
import { fontFamilyList } from './font-family-list.js';
|
|
3
|
+
import { fontShorthandFamilies } from './font-shorthand-families.js';
|
|
4
|
+
const IMPORTANT = /!\s*important\s*$/i;
|
|
5
|
+
function commentEnd(css, open) {
|
|
6
|
+
const close = css.indexOf('*/', open + 2);
|
|
7
|
+
return close === -1 ? css.length : close + 2;
|
|
8
|
+
}
|
|
9
|
+
function stringEnd(css, open) {
|
|
10
|
+
let index = open + 1;
|
|
11
|
+
while (index < css.length && css[index] !== css[open]) {
|
|
12
|
+
index += css[index] === '\\' ? 2 : 1;
|
|
13
|
+
}
|
|
14
|
+
return index + 1;
|
|
15
|
+
}
|
|
16
|
+
function piecesOf(css) {
|
|
17
|
+
const pieces = [];
|
|
18
|
+
let text = '';
|
|
19
|
+
let copied = 0;
|
|
20
|
+
let parens = 0;
|
|
21
|
+
let index = 0;
|
|
22
|
+
while (index < css.length) {
|
|
23
|
+
const char = css[index];
|
|
24
|
+
if (char === '/' && css[index + 1] === '*') {
|
|
25
|
+
text += `${css.slice(copied, index)} `;
|
|
26
|
+
index = commentEnd(css, index);
|
|
27
|
+
copied = index;
|
|
28
|
+
}
|
|
29
|
+
else if (char === '\\') {
|
|
30
|
+
index += 2;
|
|
31
|
+
}
|
|
32
|
+
else if (char === '"' || char === "'") {
|
|
33
|
+
index = stringEnd(css, index);
|
|
34
|
+
}
|
|
35
|
+
else if (char === '(') {
|
|
36
|
+
parens++;
|
|
37
|
+
index++;
|
|
38
|
+
}
|
|
39
|
+
else if (char === ')') {
|
|
40
|
+
parens = Math.max(0, parens - 1);
|
|
41
|
+
index++;
|
|
42
|
+
}
|
|
43
|
+
else if (parens === 0 && (char === '{' || char === '}' || char === ';')) {
|
|
44
|
+
pieces.push({ text: text + css.slice(copied, index), end: char });
|
|
45
|
+
text = '';
|
|
46
|
+
index++;
|
|
47
|
+
copied = index;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
index++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
pieces.push({ text: text + css.slice(copied), end: '' });
|
|
54
|
+
return pieces;
|
|
55
|
+
}
|
|
56
|
+
/** `var(--x)` or `var(--x, fallback)` as a whole, or null. */
|
|
57
|
+
function varReference(text) {
|
|
58
|
+
if (text.slice(0, 4).toLowerCase() !== 'var(' || !text.endsWith(')')) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const inner = text.slice(4, -1);
|
|
62
|
+
let depth = 0;
|
|
63
|
+
let comma = -1;
|
|
64
|
+
for (let i = 0; i < inner.length && comma === -1; i++) {
|
|
65
|
+
if (inner[i] === '(') {
|
|
66
|
+
depth++;
|
|
67
|
+
}
|
|
68
|
+
else if (inner[i] === ')') {
|
|
69
|
+
depth--;
|
|
70
|
+
}
|
|
71
|
+
else if (inner[i] === ',' && depth === 0) {
|
|
72
|
+
comma = i;
|
|
73
|
+
}
|
|
74
|
+
// A `)` that closes `var(` before the end: `var(--a) var(--b)` is no single reference.
|
|
75
|
+
if (depth < 0) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const name = (comma === -1 ? inner : inner.slice(0, comma)).trim();
|
|
80
|
+
if (!name.startsWith('--')) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const fallback = comma === -1 ? '' : inner.slice(comma + 1).trim();
|
|
84
|
+
return fallback === '' ? { name } : { name, fallback };
|
|
85
|
+
}
|
|
86
|
+
function addFamily(usage, family) {
|
|
87
|
+
if (family === undefined) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const reference = varReference(family);
|
|
91
|
+
if (reference) {
|
|
92
|
+
usage.varRefs.push(reference);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
usage.families.push(family);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function addFontShorthand(usage, value) {
|
|
99
|
+
const reference = varReference(value);
|
|
100
|
+
if (reference) {
|
|
101
|
+
usage.varRefs.push(reference);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
addFamily(usage, fontShorthandFamilies(value)?.[0]);
|
|
105
|
+
}
|
|
106
|
+
function addDeclaration(usage, text, inFontFace) {
|
|
107
|
+
const colon = text.indexOf(':');
|
|
108
|
+
if (colon === -1) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const property = text.slice(0, colon).trim();
|
|
112
|
+
const name = property.toLowerCase();
|
|
113
|
+
const value = text
|
|
114
|
+
.slice(colon + 1)
|
|
115
|
+
.trim()
|
|
116
|
+
.replace(IMPORTANT, '')
|
|
117
|
+
.trim();
|
|
118
|
+
if (inFontFace) {
|
|
119
|
+
if (name === 'font-family') {
|
|
120
|
+
usage.declared.push(...fontFamilyList(value).slice(0, 1));
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (property.startsWith('--')) {
|
|
125
|
+
usage.variables.set(property, [...(usage.variables.get(property) ?? []), value]);
|
|
126
|
+
}
|
|
127
|
+
else if (name === 'font-family') {
|
|
128
|
+
addFamily(usage, fontFamilyList(value)[0]);
|
|
129
|
+
}
|
|
130
|
+
else if (name === 'font') {
|
|
131
|
+
addFontShorthand(usage, value);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The fonts that CSS text uses, defines as custom properties, declares with
|
|
136
|
+
* `@font-face` and imports. Reads a stylesheet or the bare text of a `style`
|
|
137
|
+
* attribute. Only `font-family`, `font` and custom properties count.
|
|
138
|
+
*/
|
|
139
|
+
export function fontUsage(css) {
|
|
140
|
+
const usage = {
|
|
141
|
+
families: [],
|
|
142
|
+
varRefs: [],
|
|
143
|
+
variables: new Map(),
|
|
144
|
+
declared: [],
|
|
145
|
+
imports: cssReferences(css)
|
|
146
|
+
.filter((reference) => reference.kind === 'import')
|
|
147
|
+
.map((reference) => reference.url),
|
|
148
|
+
};
|
|
149
|
+
// One entry per open block: whether it is, or sits inside, an `@font-face`.
|
|
150
|
+
const fontFace = [];
|
|
151
|
+
for (const piece of piecesOf(css)) {
|
|
152
|
+
const inFontFace = fontFace.at(-1) ?? false;
|
|
153
|
+
if (piece.end === '{') {
|
|
154
|
+
fontFace.push(inFontFace || piece.text.trim().toLowerCase() === '@font-face');
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
addDeclaration(usage, piece.text, inFontFace);
|
|
158
|
+
if (piece.end === '}') {
|
|
159
|
+
fontFace.pop();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return usage;
|
|
163
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The same patterns as RAW_TEXT, START_TAG and ATTRIBUTE in rewrite-head-references.ts.
|
|
2
|
+
const RAW_TEXT = /<!--[\s\S]*?--!?>|<script\b[\s\S]*?<\/script\b[^>]*>|(<style\b[^>]*>)([\s\S]*?)(<\/style\b[^>]*>)/gi;
|
|
3
|
+
const START_TAG = /(<[a-z][^\s/>]*)((?:[^>"']|"[^"]*"|'[^']*')*>)/gi;
|
|
4
|
+
const ATTRIBUTE = /([^\s"'<>/=]+)(?:(\s*=\s*)("[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g;
|
|
5
|
+
/** The attributes of a start tag by lowercase name, unquoted and with `&` decoded. */
|
|
6
|
+
function attributesOf(text) {
|
|
7
|
+
const attributes = new Map();
|
|
8
|
+
for (const [, name, , value] of text.matchAll(ATTRIBUTE)) {
|
|
9
|
+
if (value === undefined) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
const quoted = value.startsWith('"') || value.startsWith("'");
|
|
13
|
+
const unquoted = quoted ? value.slice(1, -1) : value;
|
|
14
|
+
attributes.set(name.toLowerCase(), unquoted.replaceAll('&', '&'));
|
|
15
|
+
}
|
|
16
|
+
return attributes;
|
|
17
|
+
}
|
|
18
|
+
function stylesheetLinksIn(html) {
|
|
19
|
+
const links = [];
|
|
20
|
+
for (const [, name, text] of html.matchAll(START_TAG)) {
|
|
21
|
+
if (name.slice(1).toLowerCase() !== 'link') {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const attributes = attributesOf(text);
|
|
25
|
+
const rel = (attributes.get('rel') ?? '').toLowerCase().split(/\s+/);
|
|
26
|
+
const href = attributes.get('href');
|
|
27
|
+
if (rel.includes('stylesheet') && href !== undefined) {
|
|
28
|
+
links.push(href);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return links;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The CSS of preview head HTML: the body of each `<style>` and the `href` of
|
|
35
|
+
* each `<link rel="stylesheet">`. Comments and scripts are skipped.
|
|
36
|
+
*/
|
|
37
|
+
export function headStylesheets(html) {
|
|
38
|
+
const styles = [];
|
|
39
|
+
const links = [];
|
|
40
|
+
let scanned = 0;
|
|
41
|
+
for (const match of html.matchAll(RAW_TEXT)) {
|
|
42
|
+
const [text, styleStart, css] = match;
|
|
43
|
+
links.push(...stylesheetLinksIn(html.slice(scanned, match.index)));
|
|
44
|
+
if (styleStart !== undefined) {
|
|
45
|
+
styles.push(css);
|
|
46
|
+
}
|
|
47
|
+
scanned = match.index + text.length;
|
|
48
|
+
}
|
|
49
|
+
links.push(...stylesheetLinksIn(html.slice(scanned)));
|
|
50
|
+
return { styles, links };
|
|
51
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Manifest } from '@describe-me/core/types';
|
|
2
2
|
/**
|
|
3
3
|
* Warn, one line per kind, about what the manifest could not document. Prints
|
|
4
|
-
* nothing when everything is named, documented and loadable
|
|
5
|
-
*
|
|
4
|
+
* nothing when everything is named, documented and loadable, every font is
|
|
5
|
+
* loaded and no stylesheet comes from a remote host. The viewer shows the same
|
|
6
|
+
* list in its header.
|
|
6
7
|
*/
|
|
7
8
|
export declare function printDiagnostics(manifest: Manifest): void;
|
|
@@ -14,11 +14,12 @@ function plural(count, one, many) {
|
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
16
|
* Warn, one line per kind, about what the manifest could not document. Prints
|
|
17
|
-
* nothing when everything is named, documented and loadable
|
|
18
|
-
*
|
|
17
|
+
* nothing when everything is named, documented and loadable, every font is
|
|
18
|
+
* loaded and no stylesheet comes from a remote host. The viewer shows the same
|
|
19
|
+
* list in its header.
|
|
19
20
|
*/
|
|
20
21
|
export function printDiagnostics(manifest) {
|
|
21
|
-
const { anonymous, undocumented, assetsMissing } = manifestDiagnostics(manifest);
|
|
22
|
+
const { anonymous, undocumented, assetsMissing, fontsMissing, remoteStylesheets } = manifestDiagnostics(manifest);
|
|
22
23
|
if (anonymous.length > 0) {
|
|
23
24
|
console.warn(`describe-me: ${plural(anonymous.length, 'test renders', 'tests render')} an anonymous component; export it from a project module or give it a displayName`);
|
|
24
25
|
}
|
|
@@ -28,4 +29,13 @@ export function printDiagnostics(manifest) {
|
|
|
28
29
|
if (assetsMissing.length > 0) {
|
|
29
30
|
console.warn(`describe-me: ${plural(assetsMissing.length, 'asset was', 'assets were')} not found in the project and will not load in the viewer: ${listed(assetsMissing)}`);
|
|
30
31
|
}
|
|
32
|
+
if (fontsMissing.length > 0) {
|
|
33
|
+
const families = fontsMissing.map((font) => `${font.family} (${plural(font.tests, 'test', 'tests')})`);
|
|
34
|
+
const pronoun = fontsMissing.length === 1 ? 'it' : 'them';
|
|
35
|
+
console.warn(`describe-me: ${plural(fontsMissing.length, 'font family is', 'font families are')} used but never loaded: ${listed(families)} — add ${pronoun} to previewHead`);
|
|
36
|
+
}
|
|
37
|
+
if (remoteStylesheets.length > 0) {
|
|
38
|
+
const hosts = remoteStylesheets.map((stylesheet) => `${stylesheet.host} (${plural(stylesheet.frames, 'frame', 'frames')})`);
|
|
39
|
+
console.warn(`describe-me: frames load stylesheets from ${plural(remoteStylesheets.length, 'remote host', 'remote hosts')}, which the viewer fetches from the network: ${listed(hosts)}`);
|
|
40
|
+
}
|
|
31
41
|
}
|
package/dist/reporter.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ export default class DescribeMeReporter implements Reporter {
|
|
|
28
28
|
private snapshots;
|
|
29
29
|
private assets;
|
|
30
30
|
private styles;
|
|
31
|
+
private fonts;
|
|
31
32
|
private modules;
|
|
32
33
|
constructor(options?: DescribeMeReporterOptions);
|
|
33
34
|
onInit(vitest: Vitest): void;
|
|
@@ -39,12 +40,20 @@ export default class DescribeMeReporter implements Reporter {
|
|
|
39
40
|
* are dropped, and so are the empty tests that 0.4 still wrote.
|
|
40
41
|
*/
|
|
41
42
|
private seedFromPreviousRun;
|
|
43
|
+
/**
|
|
44
|
+
* Tests written before 0.5 carry Vitest's id. They get their stable id, and
|
|
45
|
+
* the old one moves to `vitestId`. Occurrences are counted over the module's
|
|
46
|
+
* full list, as the run that wrote it saw them.
|
|
47
|
+
*/
|
|
48
|
+
private withStableIds;
|
|
42
49
|
/**
|
|
43
50
|
* A test skipped in this run keeps its previous entry, so a filtered run does
|
|
44
51
|
* not erase documentation.
|
|
45
52
|
*/
|
|
46
53
|
onTestModuleEnd(module: TestModule): void;
|
|
47
54
|
onTestRunEnd(): Promise<void>;
|
|
55
|
+
/** A diagnostic must never break the manifest, so a failing audit writes empty lists. */
|
|
56
|
+
private auditFonts;
|
|
48
57
|
/** Module ids use the platform's separators. Globs expect posix ones. */
|
|
49
58
|
private isRecordedModule;
|
|
50
59
|
/** Decided before any snapshot is written, so a dropped test writes no file. */
|
package/dist/reporter.js
CHANGED
|
@@ -10,11 +10,16 @@ import { collectComponentDocsSafely } from './collect-component-docs-safely.js';
|
|
|
10
10
|
import { compileGlobs } from './compile-globs.js';
|
|
11
11
|
import { componentEntries } from './component-entries.js';
|
|
12
12
|
import { fileFilter } from './file-filter.js';
|
|
13
|
+
import { FontAudit } from './font-audit.js';
|
|
13
14
|
import { isRecordedTest } from './is-recorded-test.js';
|
|
14
15
|
import { printDiagnostics } from './print-diagnostics.js';
|
|
15
16
|
import { SnapshotStore } from './snapshot-store.js';
|
|
17
|
+
import { stableTestIds } from './stable-test-ids.js';
|
|
16
18
|
import { StyleStore } from './style-store.js';
|
|
17
19
|
import { testPath } from './test-path.js';
|
|
20
|
+
function toPosix(moduleId) {
|
|
21
|
+
return moduleId.split(sep).join('/');
|
|
22
|
+
}
|
|
18
23
|
function recordOf(tc) {
|
|
19
24
|
return tc.meta()[META_KEY];
|
|
20
25
|
}
|
|
@@ -27,6 +32,7 @@ export default class DescribeMeReporter {
|
|
|
27
32
|
snapshots;
|
|
28
33
|
assets;
|
|
29
34
|
styles;
|
|
35
|
+
fonts;
|
|
30
36
|
modules = new Map();
|
|
31
37
|
constructor(options = {}) {
|
|
32
38
|
this.outDirOption = options.outDir ?? '.describe-me';
|
|
@@ -43,6 +49,7 @@ export default class DescribeMeReporter {
|
|
|
43
49
|
this.snapshots = new SnapshotStore(this.outDir);
|
|
44
50
|
this.assets = new AssetStore(this.outDir, this.root);
|
|
45
51
|
this.styles = new StyleStore(this.outDir);
|
|
52
|
+
this.fonts = new FontAudit(this.outDir);
|
|
46
53
|
this.seedFromPreviousRun();
|
|
47
54
|
if (this.previewHead !== undefined && /<script\b/i.test(this.previewHead)) {
|
|
48
55
|
console.warn('describe-me: previewHead contains a <script>, which the viewer never runs.');
|
|
@@ -65,7 +72,7 @@ export default class DescribeMeReporter {
|
|
|
65
72
|
try {
|
|
66
73
|
const previous = JSON.parse(readFileSync(file, 'utf8'));
|
|
67
74
|
for (const mod of previous.modules ?? []) {
|
|
68
|
-
const tests = mod.
|
|
75
|
+
const tests = this.withStableIds(mod).filter((test) => isRecordedTest(test));
|
|
69
76
|
if (tests.length > 0 &&
|
|
70
77
|
this.isRecordedModule(mod.id) &&
|
|
71
78
|
existsSync(resolve(this.root, mod.id))) {
|
|
@@ -77,6 +84,20 @@ export default class DescribeMeReporter {
|
|
|
77
84
|
// corrupt or incompatible manifest: start fresh
|
|
78
85
|
}
|
|
79
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Tests written before 0.5 carry Vitest's id. They get their stable id, and
|
|
89
|
+
* the old one moves to `vitestId`. Occurrences are counted over the module's
|
|
90
|
+
* full list, as the run that wrote it saw them.
|
|
91
|
+
*/
|
|
92
|
+
withStableIds(mod) {
|
|
93
|
+
const ids = stableTestIds(toPosix(mod.id), mod.tests);
|
|
94
|
+
return mod.tests.map((test, i) => {
|
|
95
|
+
if (test.vitestId !== undefined) {
|
|
96
|
+
return test;
|
|
97
|
+
}
|
|
98
|
+
return { ...test, id: ids[i], vitestId: test.id };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
80
101
|
/**
|
|
81
102
|
* A test skipped in this run keeps its previous entry, so a filtered run does
|
|
82
103
|
* not erase documentation.
|
|
@@ -89,8 +110,12 @@ export default class DescribeMeReporter {
|
|
|
89
110
|
}
|
|
90
111
|
const previous = this.modules.get(id);
|
|
91
112
|
const tests = [];
|
|
92
|
-
|
|
93
|
-
|
|
113
|
+
// Over every test, skipped ones included, so an id does not depend on
|
|
114
|
+
// which tests recorded frames or on `-t` and `.only`.
|
|
115
|
+
const cases = Array.from(module.children.allTests());
|
|
116
|
+
const stableIds = stableTestIds(toPosix(id), cases.map((tc) => ({ path: testPath(tc), name: tc.name })));
|
|
117
|
+
for (const [i, tc] of cases.entries()) {
|
|
118
|
+
const test = this.manifestTestOf(tc, stableIds[i], previous);
|
|
94
119
|
if (test) {
|
|
95
120
|
tests.push(test);
|
|
96
121
|
}
|
|
@@ -124,25 +149,40 @@ export default class DescribeMeReporter {
|
|
|
124
149
|
this.snapshots.collectGarbage(snapshotFiles);
|
|
125
150
|
const styleFiles = this.styles.collectGarbage(snapshotFiles);
|
|
126
151
|
this.assets.collectGarbage([...snapshotFiles, ...styleFiles], head === undefined ? [] : [head]);
|
|
152
|
+
this.auditFonts(manifest);
|
|
127
153
|
writeFileSync(join(this.outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
128
154
|
printDiagnostics(manifest);
|
|
129
155
|
}
|
|
156
|
+
/** A diagnostic must never break the manifest, so a failing audit writes empty lists. */
|
|
157
|
+
auditFonts(manifest) {
|
|
158
|
+
try {
|
|
159
|
+
const { fontsMissing, remoteStylesheets } = this.fonts.audit(manifest);
|
|
160
|
+
manifest.fontsMissing = fontsMissing;
|
|
161
|
+
manifest.remoteStylesheets = remoteStylesheets;
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
165
|
+
console.warn(`describe-me: the font audit failed: ${message}`);
|
|
166
|
+
manifest.fontsMissing = [];
|
|
167
|
+
manifest.remoteStylesheets = [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
130
170
|
/** Module ids use the platform's separators. Globs expect posix ones. */
|
|
131
171
|
isRecordedModule(id) {
|
|
132
|
-
return this.isRecordedFile(id
|
|
172
|
+
return this.isRecordedFile(toPosix(id));
|
|
133
173
|
}
|
|
134
174
|
/** Decided before any snapshot is written, so a dropped test writes no file. */
|
|
135
|
-
manifestTestOf(tc, previous) {
|
|
175
|
+
manifestTestOf(tc, stableId, previous) {
|
|
136
176
|
const record = recordOf(tc);
|
|
137
177
|
if (record && isRecordedTest(record)) {
|
|
138
|
-
return this.toManifestTest(tc, record);
|
|
178
|
+
return this.toManifestTest(tc, stableId, record);
|
|
139
179
|
}
|
|
140
180
|
if (tc.result().state !== 'skipped') {
|
|
141
181
|
return undefined;
|
|
142
182
|
}
|
|
143
|
-
return previous?.tests.find((test) => test.id ===
|
|
183
|
+
return previous?.tests.find((test) => test.id === stableId && isRecordedTest(test));
|
|
144
184
|
}
|
|
145
|
-
toManifestTest(tc, record) {
|
|
185
|
+
toManifestTest(tc, stableId, record) {
|
|
146
186
|
const result = tc.result();
|
|
147
187
|
const frames = record.frames.map((frame) => ({
|
|
148
188
|
id: frame.id,
|
|
@@ -153,7 +193,8 @@ export default class DescribeMeReporter {
|
|
|
153
193
|
snapshot: this.writeSnapshot(frame.snapshot, record.origin),
|
|
154
194
|
}));
|
|
155
195
|
return {
|
|
156
|
-
id:
|
|
196
|
+
id: stableId,
|
|
197
|
+
vitestId: tc.id,
|
|
157
198
|
name: tc.name,
|
|
158
199
|
path: testPath(tc),
|
|
159
200
|
fullName: tc.fullName,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The CSS a serialized snapshot carries, and the stylesheets it links. */
|
|
2
|
+
export interface SnapshotStyles {
|
|
3
|
+
/** `_cssText` values that are not style references, and the text of `<style>` elements. */
|
|
4
|
+
cssTexts: string[];
|
|
5
|
+
/** The chunk hashes of `describe-me-style:` references, in order. */
|
|
6
|
+
chunks: string[];
|
|
7
|
+
/** `style` attribute values. */
|
|
8
|
+
styleAttributes: string[];
|
|
9
|
+
/** The `href` of each `<link rel="stylesheet">` that rrweb did not inline. */
|
|
10
|
+
stylesheetLinks: string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Collect the CSS of an rrweb serialized node and everything below it. rrweb
|
|
14
|
+
* keeps shadow roots and iframe documents among the child nodes, so they are
|
|
15
|
+
* read too.
|
|
16
|
+
*/
|
|
17
|
+
export declare function snapshotStyles(node: unknown): SnapshotStyles;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { STYLE_URL_PREFIX } from '@describe-me/core/types';
|
|
2
|
+
function isNode(value) {
|
|
3
|
+
return typeof value === 'object' && value !== null;
|
|
4
|
+
}
|
|
5
|
+
function childrenOf(node) {
|
|
6
|
+
return Array.isArray(node.childNodes) ? node.childNodes.filter(isNode) : [];
|
|
7
|
+
}
|
|
8
|
+
function isStylesheetLink(node, attributes) {
|
|
9
|
+
const rel = attributes.rel;
|
|
10
|
+
return (node.tagName === 'link' &&
|
|
11
|
+
typeof rel === 'string' &&
|
|
12
|
+
rel.toLowerCase().split(/\s+/).includes('stylesheet'));
|
|
13
|
+
}
|
|
14
|
+
function addCssText(styles, cssText) {
|
|
15
|
+
if (cssText.startsWith(STYLE_URL_PREFIX)) {
|
|
16
|
+
styles.chunks.push(...cssText.slice(STYLE_URL_PREFIX.length).split('+'));
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
styles.cssTexts.push(cssText);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** rrweb writes the text of a `<style>` only when it could not read the sheet. */
|
|
23
|
+
function addStyleText(styles, node) {
|
|
24
|
+
for (const child of childrenOf(node)) {
|
|
25
|
+
if (typeof child.textContent === 'string' && child.textContent.trim() !== '') {
|
|
26
|
+
styles.cssTexts.push(child.textContent);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function visit(node, styles) {
|
|
31
|
+
const attributes = isNode(node.attributes) ? node.attributes : {};
|
|
32
|
+
const { _cssText: cssText, style, href } = attributes;
|
|
33
|
+
if (typeof cssText === 'string') {
|
|
34
|
+
addCssText(styles, cssText);
|
|
35
|
+
}
|
|
36
|
+
else if (isStylesheetLink(node, attributes) && typeof href === 'string') {
|
|
37
|
+
styles.stylesheetLinks.push(href);
|
|
38
|
+
}
|
|
39
|
+
if (typeof style === 'string') {
|
|
40
|
+
styles.styleAttributes.push(style);
|
|
41
|
+
}
|
|
42
|
+
if (node.tagName === 'style') {
|
|
43
|
+
addStyleText(styles, node);
|
|
44
|
+
}
|
|
45
|
+
for (const child of childrenOf(node)) {
|
|
46
|
+
visit(child, styles);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Collect the CSS of an rrweb serialized node and everything below it. rrweb
|
|
51
|
+
* keeps shadow roots and iframe documents among the child nodes, so they are
|
|
52
|
+
* read too.
|
|
53
|
+
*/
|
|
54
|
+
export function snapshotStyles(node) {
|
|
55
|
+
const styles = {
|
|
56
|
+
cssTexts: [],
|
|
57
|
+
chunks: [],
|
|
58
|
+
styleAttributes: [],
|
|
59
|
+
stylesheetLinks: [],
|
|
60
|
+
};
|
|
61
|
+
if (isNode(node)) {
|
|
62
|
+
visit(node, styles);
|
|
63
|
+
}
|
|
64
|
+
return styles;
|
|
65
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A test id that survives edits to other tests in its file. `moduleId` is
|
|
3
|
+
* root-relative with `/` separators, so Windows and posix agree.
|
|
4
|
+
*/
|
|
5
|
+
export declare function stableTestId(moduleId: string, path: readonly string[], name: string, occurrence: number): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* A test id that survives edits to other tests in its file. `moduleId` is
|
|
4
|
+
* root-relative with `/` separators, so Windows and posix agree.
|
|
5
|
+
*/
|
|
6
|
+
export function stableTestId(moduleId, path, name, occurrence) {
|
|
7
|
+
let input = [moduleId, ...path, name].join('\0');
|
|
8
|
+
if (occurrence > 1) {
|
|
9
|
+
input += `\0${occurrence}`;
|
|
10
|
+
}
|
|
11
|
+
return createHash('sha1').update(input).digest('hex').slice(0, 12);
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { stableTestId } from './stable-test-id.js';
|
|
2
|
+
/** One stable id per test of a module, counting repeated names 1-based in the given order. */
|
|
3
|
+
export function stableTestIds(moduleId, tests) {
|
|
4
|
+
const seen = new Map();
|
|
5
|
+
const ids = [];
|
|
6
|
+
for (const test of tests) {
|
|
7
|
+
const key = [...test.path, test.name].join('\0');
|
|
8
|
+
const occurrence = (seen.get(key) ?? 0) + 1;
|
|
9
|
+
seen.set(key, occurrence);
|
|
10
|
+
ids.push(stableTestId(moduleId, test.path, test.name, occurrence));
|
|
11
|
+
}
|
|
12
|
+
return ids;
|
|
13
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { fontFamilyKey } from './font-family-key.js';
|
|
2
|
+
const CSS_WIDE_KEYWORDS = ['inherit', 'initial', 'unset', 'revert', 'revert-layer'];
|
|
3
|
+
const GENERIC_FAMILIES = [
|
|
4
|
+
'serif',
|
|
5
|
+
'sans-serif',
|
|
6
|
+
'monospace',
|
|
7
|
+
'cursive',
|
|
8
|
+
'fantasy',
|
|
9
|
+
'system-ui',
|
|
10
|
+
'ui-serif',
|
|
11
|
+
'ui-sans-serif',
|
|
12
|
+
'ui-monospace',
|
|
13
|
+
'ui-rounded',
|
|
14
|
+
'math',
|
|
15
|
+
'emoji',
|
|
16
|
+
'fangsong',
|
|
17
|
+
];
|
|
18
|
+
// Roboto, Ubuntu, Oxygen, Fira, Cantarell, Noto Sans, Open Sans and Inter are
|
|
19
|
+
// left out on purpose. They come preinstalled on some systems, but they are
|
|
20
|
+
// Google Fonts too, and a page that uses them usually has to load them.
|
|
21
|
+
const INSTALLED_FAMILIES = [
|
|
22
|
+
'-apple-system',
|
|
23
|
+
'BlinkMacSystemFont',
|
|
24
|
+
'SFMono-Regular',
|
|
25
|
+
'San Francisco',
|
|
26
|
+
'Verdana',
|
|
27
|
+
'Tahoma',
|
|
28
|
+
'Trebuchet MS',
|
|
29
|
+
'Georgia',
|
|
30
|
+
'Menlo',
|
|
31
|
+
'Monaco',
|
|
32
|
+
'Consolas',
|
|
33
|
+
'Impact',
|
|
34
|
+
'Comic Sans MS',
|
|
35
|
+
'Book Antiqua',
|
|
36
|
+
'Cambria',
|
|
37
|
+
'Calibri',
|
|
38
|
+
'Candara',
|
|
39
|
+
'Gill Sans',
|
|
40
|
+
'Optima',
|
|
41
|
+
'Geneva',
|
|
42
|
+
'Symbol',
|
|
43
|
+
'Webdings',
|
|
44
|
+
'Wingdings',
|
|
45
|
+
'Apple Color Emoji',
|
|
46
|
+
'Noto Color Emoji',
|
|
47
|
+
'Android Emoji',
|
|
48
|
+
];
|
|
49
|
+
/** Each covers its whole family, so `Times New Roman` counts and `Timeless` does not. */
|
|
50
|
+
const INSTALLED_PREFIXES = [
|
|
51
|
+
'Segoe UI',
|
|
52
|
+
'Helvetica',
|
|
53
|
+
'Arial',
|
|
54
|
+
'Times',
|
|
55
|
+
'Courier',
|
|
56
|
+
'Lucida',
|
|
57
|
+
'Palatino',
|
|
58
|
+
'SF Pro',
|
|
59
|
+
'SF Mono',
|
|
60
|
+
'Avenir',
|
|
61
|
+
'DejaVu',
|
|
62
|
+
'Liberation',
|
|
63
|
+
'PingFang',
|
|
64
|
+
'Hiragino',
|
|
65
|
+
'Microsoft YaHei',
|
|
66
|
+
'Yu Gothic',
|
|
67
|
+
'Meiryo',
|
|
68
|
+
].map(fontFamilyKey);
|
|
69
|
+
const EXACT = new Set([...CSS_WIDE_KEYWORDS, ...GENERIC_FAMILIES, ...INSTALLED_FAMILIES].map(fontFamilyKey));
|
|
70
|
+
/**
|
|
71
|
+
* Whether a family needs no loading: a CSS-wide keyword, a generic family, or
|
|
72
|
+
* a family that ships with common operating systems.
|
|
73
|
+
*/
|
|
74
|
+
export function isSystemFontFamily(name) {
|
|
75
|
+
const key = fontFamilyKey(name);
|
|
76
|
+
return (EXACT.has(key) ||
|
|
77
|
+
INSTALLED_PREFIXES.some((prefix) => key === prefix || key.startsWith(`${prefix} `)));
|
|
78
|
+
}
|