as-test 1.0.6 → 1.0.7
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 +13 -0
- package/README.md +34 -0
- package/as-test.config.schema.json +39 -0
- package/assembly/index.ts +32 -3
- package/assembly/src/fuzz.ts +31 -7
- package/assembly/util/wipc.ts +14 -4
- package/bin/commands/fuzz-core.js +30 -2
- package/bin/commands/init-core.js +1 -1
- package/bin/commands/run-core.js +67 -0
- package/bin/coverage-points.js +173 -0
- package/bin/index.js +62 -4
- package/bin/reporters/default.js +103 -4
- package/bin/types.js +9 -0
- package/bin/util.js +16 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 2026-03-31 - v1.0.7
|
|
6
|
+
|
|
7
|
+
### Coverage
|
|
8
|
+
|
|
9
|
+
- feat: make CLI coverage output easier to scan with a summarized coverage block, per-file breakdown, grouped uncovered points, clickable `file:line:column` locations, trimmed source snippets, aligned gap columns, source-aware labels such as `Function`, `Method`, `Constructor`, `Property`, and `Call`, and coverage-point ignore rules for labels, names, locations, and snippets.
|
|
10
|
+
|
|
11
|
+
### Fuzzing
|
|
12
|
+
|
|
13
|
+
- feat: allow `fuzz(...)` and `xfuzz(...)` targets to override their own operation count with an optional third argument, so one file can mix short smoke fuzzers and heavier targets without changing the global `fuzz.runs` config.
|
|
14
|
+
- feat: make `--runs` / `--fuzz-runs` accept absolute and relative overrides such as `500`, `1.5x`, `+10%`, and `+100000`, applying them to each fuzzer's effective run count for that command.
|
|
15
|
+
|
|
3
16
|
## 2026-03-31 - v1.0.6
|
|
4
17
|
|
|
5
18
|
### Fuzzing
|
package/README.md
CHANGED
|
@@ -83,6 +83,23 @@ Minimal `as-test.config.json`:
|
|
|
83
83
|
}
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
+
Coverage point filtering is configurable when you want to ignore known-noisy gaps:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"coverage": {
|
|
91
|
+
"enabled": true,
|
|
92
|
+
"include": ["assembly/src/**/*.ts"],
|
|
93
|
+
"ignore": {
|
|
94
|
+
"labels": ["Call"],
|
|
95
|
+
"names": ["panic", "serialize"],
|
|
96
|
+
"locations": ["assembly/src/fuzz.ts:38:*"],
|
|
97
|
+
"snippets": ["*message: string*"]
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
86
103
|
## Writing Tests
|
|
87
104
|
|
|
88
105
|
Tests usually live in `assembly/__tests__/*.spec.ts`.
|
|
@@ -218,6 +235,23 @@ fuzz("bounded integer addition", (left: i32, right: i32): bool => {
|
|
|
218
235
|
});
|
|
219
236
|
```
|
|
220
237
|
|
|
238
|
+
Pass a third argument to override the operation count for one target without changing the global fuzz config:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
fuzz("hot path stays stable", (): void => {
|
|
242
|
+
expect(1 + 1).toBe(2);
|
|
243
|
+
}, 250);
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
You can still override fuzz runs from the CLI when you want to force a different count for the current command:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
npx ast fuzz --runs 500
|
|
250
|
+
npx ast fuzz --runs 1.5x
|
|
251
|
+
npx ast fuzz --runs +10%
|
|
252
|
+
npx ast fuzz --runs +100000
|
|
253
|
+
```
|
|
254
|
+
|
|
221
255
|
If you used `npx ast init` with a fuzzer example, the config is already there. Otherwise, add a `fuzz` block to `as-test.config.json` so `npx ast fuzz` knows what to build:
|
|
222
256
|
|
|
223
257
|
```json
|
|
@@ -121,6 +121,45 @@
|
|
|
121
121
|
"type": "string"
|
|
122
122
|
},
|
|
123
123
|
"default": []
|
|
124
|
+
},
|
|
125
|
+
"ignore": {
|
|
126
|
+
"type": "object",
|
|
127
|
+
"description": "Ignore specific coverage points by derived label, symbol name, location, or source snippet.",
|
|
128
|
+
"additionalProperties": true,
|
|
129
|
+
"properties": {
|
|
130
|
+
"labels": {
|
|
131
|
+
"type": "array",
|
|
132
|
+
"description": "Ignore all points whose rendered label matches, such as Call, Method, Constructor, Property, or Function.",
|
|
133
|
+
"items": {
|
|
134
|
+
"type": "string"
|
|
135
|
+
},
|
|
136
|
+
"default": []
|
|
137
|
+
},
|
|
138
|
+
"names": {
|
|
139
|
+
"type": "array",
|
|
140
|
+
"description": "Ignore declarations or calls whose extracted symbol name matches a glob pattern.",
|
|
141
|
+
"items": {
|
|
142
|
+
"type": "string"
|
|
143
|
+
},
|
|
144
|
+
"default": []
|
|
145
|
+
},
|
|
146
|
+
"locations": {
|
|
147
|
+
"type": "array",
|
|
148
|
+
"description": "Ignore points whose file:line:column location matches a glob pattern.",
|
|
149
|
+
"items": {
|
|
150
|
+
"type": "string"
|
|
151
|
+
},
|
|
152
|
+
"default": []
|
|
153
|
+
},
|
|
154
|
+
"snippets": {
|
|
155
|
+
"type": "array",
|
|
156
|
+
"description": "Ignore points whose trimmed source snippet matches a glob pattern.",
|
|
157
|
+
"items": {
|
|
158
|
+
"type": "string"
|
|
159
|
+
},
|
|
160
|
+
"default": []
|
|
161
|
+
}
|
|
162
|
+
}
|
|
124
163
|
}
|
|
125
164
|
}
|
|
126
165
|
}
|
package/assembly/index.ts
CHANGED
|
@@ -169,8 +169,9 @@ export function xit(description: string, callback: () => void): void {
|
|
|
169
169
|
export function fuzz<T extends Function>(
|
|
170
170
|
description: string,
|
|
171
171
|
callback: T,
|
|
172
|
+
operations: i32 = 0,
|
|
172
173
|
): FuzzerBase {
|
|
173
|
-
const entry = createFuzzer(description, callback);
|
|
174
|
+
const entry = createFuzzer(description, callback, false, operations);
|
|
174
175
|
entryFuzzers.push(entry);
|
|
175
176
|
return entry;
|
|
176
177
|
}
|
|
@@ -178,8 +179,9 @@ export function fuzz<T extends Function>(
|
|
|
178
179
|
export function xfuzz<T extends Function>(
|
|
179
180
|
description: string,
|
|
180
181
|
callback: T,
|
|
182
|
+
operations: i32 = 0,
|
|
181
183
|
): FuzzerBase {
|
|
182
|
-
const entry = createFuzzer(description, callback, true);
|
|
184
|
+
const entry = createFuzzer(description, callback, true, operations);
|
|
183
185
|
entryFuzzers.push(entry);
|
|
184
186
|
return entry;
|
|
185
187
|
}
|
|
@@ -421,6 +423,8 @@ function containsOnlySuites(values: Suite[]): bool {
|
|
|
421
423
|
class FuzzConfig {
|
|
422
424
|
runs: i32 = 1000;
|
|
423
425
|
seed: u64 = 1337;
|
|
426
|
+
runsOverrideKind: i32 = 0;
|
|
427
|
+
runsOverrideValue: f64 = 0.0;
|
|
424
428
|
}
|
|
425
429
|
|
|
426
430
|
class FuzzReport {
|
|
@@ -444,7 +448,10 @@ function runFuzzers(): void {
|
|
|
444
448
|
for (let i = 0; i < entryFuzzers.length; i++) {
|
|
445
449
|
const fuzzer = unchecked(entryFuzzers[i]);
|
|
446
450
|
prepareFuzzIteration();
|
|
447
|
-
const result = fuzzer.run(
|
|
451
|
+
const result = fuzzer.run(
|
|
452
|
+
config.seed,
|
|
453
|
+
resolveFuzzerRuns(fuzzer, config),
|
|
454
|
+
);
|
|
448
455
|
report.fuzzers.push(result);
|
|
449
456
|
}
|
|
450
457
|
sendReport(report.serialize());
|
|
@@ -455,9 +462,31 @@ function requestFuzzConfig(): FuzzConfig {
|
|
|
455
462
|
const reply = requestHostFuzzConfig();
|
|
456
463
|
out.runs = reply.runs;
|
|
457
464
|
out.seed = reply.seed;
|
|
465
|
+
out.runsOverrideKind = reply.runsOverrideKind;
|
|
466
|
+
out.runsOverrideValue = reply.runsOverrideValue;
|
|
458
467
|
return out;
|
|
459
468
|
}
|
|
460
469
|
|
|
470
|
+
function resolveFuzzerRuns(fuzzer: FuzzerBase, config: FuzzConfig): i32 {
|
|
471
|
+
const baseRuns = fuzzer.runsOr(config.runs);
|
|
472
|
+
const resolved = applyFuzzRunsOverride(
|
|
473
|
+
baseRuns,
|
|
474
|
+
config.runsOverrideKind,
|
|
475
|
+
config.runsOverrideValue,
|
|
476
|
+
);
|
|
477
|
+
return resolved > 0 ? resolved : 1;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function applyFuzzRunsOverride(baseRuns: i32, kind: i32, value: f64): i32 {
|
|
481
|
+
if (kind == 1) return <i32>value;
|
|
482
|
+
if (kind == 2) return <i32>Math.round(<f64>baseRuns * value);
|
|
483
|
+
if (kind == 3) return baseRuns + <i32>value;
|
|
484
|
+
if (kind == 4) {
|
|
485
|
+
return baseRuns + <i32>Math.round((<f64>baseRuns * value) / 100.0);
|
|
486
|
+
}
|
|
487
|
+
return baseRuns;
|
|
488
|
+
}
|
|
489
|
+
|
|
461
490
|
function registerSuite(
|
|
462
491
|
description: string,
|
|
463
492
|
callback: () => void,
|
package/assembly/src/fuzz.ts
CHANGED
|
@@ -186,15 +186,21 @@ export class FuzzSeed {
|
|
|
186
186
|
export abstract class FuzzerBase {
|
|
187
187
|
public name: string;
|
|
188
188
|
public skipped: bool;
|
|
189
|
-
|
|
189
|
+
public operations: i32;
|
|
190
|
+
constructor(name: string, skipped: bool = false, operations: i32 = 0) {
|
|
190
191
|
this.name = name;
|
|
191
192
|
this.skipped = skipped;
|
|
193
|
+
this.operations = operations > 0 ? operations : 0;
|
|
192
194
|
}
|
|
193
195
|
|
|
194
196
|
generate<T extends Function>(_generator: T): this {
|
|
195
197
|
return this;
|
|
196
198
|
}
|
|
197
199
|
|
|
200
|
+
runsOr(defaultRuns: i32): i32 {
|
|
201
|
+
return this.operations > 0 ? this.operations : defaultRuns;
|
|
202
|
+
}
|
|
203
|
+
|
|
198
204
|
abstract run(seed: u64, runs: i32): FuzzerResult;
|
|
199
205
|
}
|
|
200
206
|
|
|
@@ -377,8 +383,9 @@ export class Fuzzer0<R> extends FuzzerBase {
|
|
|
377
383
|
name: string,
|
|
378
384
|
private callback: () => R,
|
|
379
385
|
skipped: bool = false,
|
|
386
|
+
operations: i32 = 0,
|
|
380
387
|
) {
|
|
381
|
-
super(name, skipped);
|
|
388
|
+
super(name, skipped, operations);
|
|
382
389
|
this.returnsBool = !isVoid<R>();
|
|
383
390
|
}
|
|
384
391
|
|
|
@@ -431,8 +438,9 @@ export class Fuzzer1<A, R> extends FuzzerBase {
|
|
|
431
438
|
name: string,
|
|
432
439
|
private callback: (a: A) => R,
|
|
433
440
|
skipped: bool = false,
|
|
441
|
+
operations: i32 = 0,
|
|
434
442
|
) {
|
|
435
|
-
super(name, skipped);
|
|
443
|
+
super(name, skipped, operations);
|
|
436
444
|
this.returnsBool = !isVoid<R>();
|
|
437
445
|
}
|
|
438
446
|
|
|
@@ -494,8 +502,9 @@ export class Fuzzer2<A, B, R> extends FuzzerBase {
|
|
|
494
502
|
name: string,
|
|
495
503
|
private callback: (a: A, b: B) => R,
|
|
496
504
|
skipped: bool = false,
|
|
505
|
+
operations: i32 = 0,
|
|
497
506
|
) {
|
|
498
|
-
super(name, skipped);
|
|
507
|
+
super(name, skipped, operations);
|
|
499
508
|
this.returnsBool = !isVoid<R>();
|
|
500
509
|
}
|
|
501
510
|
|
|
@@ -559,8 +568,9 @@ export class Fuzzer3<A, B, C, R> extends FuzzerBase {
|
|
|
559
568
|
name: string,
|
|
560
569
|
private callback: (a: A, b: B, c: C) => R,
|
|
561
570
|
skipped: bool = false,
|
|
571
|
+
operations: i32 = 0,
|
|
562
572
|
) {
|
|
563
|
-
super(name, skipped);
|
|
573
|
+
super(name, skipped, operations);
|
|
564
574
|
this.returnsBool = !isVoid<R>();
|
|
565
575
|
}
|
|
566
576
|
|
|
@@ -629,16 +639,23 @@ export function createFuzzer<T extends Function>(
|
|
|
629
639
|
name: string,
|
|
630
640
|
callback: T,
|
|
631
641
|
skipped: bool = false,
|
|
642
|
+
operations: i32 = 0,
|
|
632
643
|
): FuzzerBase {
|
|
633
644
|
const length = callback.length;
|
|
634
645
|
if (length == 0) {
|
|
635
|
-
return new Fuzzer0<usize>(
|
|
646
|
+
return new Fuzzer0<usize>(
|
|
647
|
+
name,
|
|
648
|
+
changetype<() => usize>(callback),
|
|
649
|
+
skipped,
|
|
650
|
+
operations,
|
|
651
|
+
);
|
|
636
652
|
}
|
|
637
653
|
if (length == 1) {
|
|
638
654
|
return new Fuzzer1<usize, usize>(
|
|
639
655
|
name,
|
|
640
656
|
changetype<(a: usize) => usize>(callback),
|
|
641
657
|
skipped,
|
|
658
|
+
operations,
|
|
642
659
|
);
|
|
643
660
|
}
|
|
644
661
|
if (length == 2) {
|
|
@@ -646,6 +663,7 @@ export function createFuzzer<T extends Function>(
|
|
|
646
663
|
name,
|
|
647
664
|
changetype<(a: usize, b: usize) => usize>(callback),
|
|
648
665
|
skipped,
|
|
666
|
+
operations,
|
|
649
667
|
);
|
|
650
668
|
}
|
|
651
669
|
if (length == 3) {
|
|
@@ -653,10 +671,16 @@ export function createFuzzer<T extends Function>(
|
|
|
653
671
|
name,
|
|
654
672
|
changetype<(a: usize, b: usize, c: usize) => usize>(callback),
|
|
655
673
|
skipped,
|
|
674
|
+
operations,
|
|
656
675
|
);
|
|
657
676
|
}
|
|
658
677
|
panic();
|
|
659
|
-
return new Fuzzer0<usize>(
|
|
678
|
+
return new Fuzzer0<usize>(
|
|
679
|
+
name,
|
|
680
|
+
changetype<() => usize>(callback),
|
|
681
|
+
skipped,
|
|
682
|
+
operations,
|
|
683
|
+
);
|
|
660
684
|
}
|
|
661
685
|
|
|
662
686
|
function buildAlphabet(options: StringOptions): i32[] {
|
package/assembly/util/wipc.ts
CHANGED
|
@@ -52,6 +52,8 @@ export class SnapshotReply {
|
|
|
52
52
|
export class FuzzConfigReply {
|
|
53
53
|
public runs: i32 = 1000;
|
|
54
54
|
public seed: u64 = 1337;
|
|
55
|
+
public runsOverrideKind: i32 = 0;
|
|
56
|
+
public runsOverrideValue: f64 = 0.0;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
export function sendAssertionFailure(
|
|
@@ -141,13 +143,21 @@ export function requestFuzzConfig(): FuzzConfigReply {
|
|
|
141
143
|
if (!body.length) {
|
|
142
144
|
return new FuzzConfigReply();
|
|
143
145
|
}
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
+
const first = body.indexOf("\n");
|
|
147
|
+
if (first < 0) return new FuzzConfigReply();
|
|
146
148
|
const reply = new FuzzConfigReply();
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
+
const second = body.indexOf("\n", first + 1);
|
|
150
|
+
const third = second >= 0 ? body.indexOf("\n", second + 1) : -1;
|
|
151
|
+
const runs = body.slice(0, first);
|
|
152
|
+
const seed =
|
|
153
|
+
second >= 0 ? body.slice(first + 1, second) : body.slice(first + 1);
|
|
154
|
+
const kind =
|
|
155
|
+
second >= 0 && third >= 0 ? body.slice(second + 1, third) : "";
|
|
156
|
+
const value = third >= 0 ? body.slice(third + 1) : "";
|
|
149
157
|
if (runs.length) reply.runs = I32.parseInt(runs);
|
|
150
158
|
if (seed.length) reply.seed = U64.parseInt(seed);
|
|
159
|
+
if (kind.length) reply.runsOverrideKind = I32.parseInt(kind);
|
|
160
|
+
if (value.length) reply.runsOverrideValue = parseFloat(value);
|
|
151
161
|
return reply;
|
|
152
162
|
}
|
|
153
163
|
|
|
@@ -29,12 +29,39 @@ export async function fuzz(configPath = DEFAULT_CONFIG_PATH, selectors = [], mod
|
|
|
29
29
|
return results;
|
|
30
30
|
}
|
|
31
31
|
function resolveFuzzConfig(raw, overrides) {
|
|
32
|
-
const config = Object.assign({}, raw
|
|
32
|
+
const config = Object.assign({}, raw);
|
|
33
|
+
if (typeof overrides.seed == "number") {
|
|
34
|
+
config.seed = overrides.seed;
|
|
35
|
+
}
|
|
36
|
+
if (typeof overrides.runs == "number") {
|
|
37
|
+
config.runs = overrides.runs;
|
|
38
|
+
}
|
|
39
|
+
config.runsOverrideKind = 0;
|
|
40
|
+
config.runsOverrideValue = 0;
|
|
41
|
+
if (overrides.runsOverride) {
|
|
42
|
+
config.runsOverrideKind = encodeRunsOverrideKind(overrides.runsOverride.kind);
|
|
43
|
+
config.runsOverrideValue = overrides.runsOverride.value;
|
|
44
|
+
if (overrides.runsOverride.kind == "set") {
|
|
45
|
+
config.runs = Math.max(1, Math.round(overrides.runsOverride.value));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
33
48
|
if (config.target != "bindings") {
|
|
34
49
|
throw new Error(`fuzz target must be "bindings"; received "${config.target}"`);
|
|
35
50
|
}
|
|
36
51
|
return config;
|
|
37
52
|
}
|
|
53
|
+
function encodeRunsOverrideKind(kind) {
|
|
54
|
+
switch (kind) {
|
|
55
|
+
case "set":
|
|
56
|
+
return 1;
|
|
57
|
+
case "scale":
|
|
58
|
+
return 2;
|
|
59
|
+
case "add":
|
|
60
|
+
return 3;
|
|
61
|
+
case "percent-add":
|
|
62
|
+
return 4;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
38
65
|
async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStartedAt, buildFinishedAt, buildTime, modeName) {
|
|
39
66
|
const startedAt = Date.now();
|
|
40
67
|
const artifact = resolveArtifactFileName(file, duplicateBasenames, modeName);
|
|
@@ -48,7 +75,8 @@ async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStar
|
|
|
48
75
|
if (type == 0x02) {
|
|
49
76
|
const event = JSON.parse(payload.toString("utf8"));
|
|
50
77
|
if (String(event.kind ?? "") == "fuzz:config") {
|
|
51
|
-
|
|
78
|
+
const resolved = config;
|
|
79
|
+
respond(`${config.runs}\n${config.seed}\n${resolved.runsOverrideKind ?? 0}\n${resolved.runsOverrideValue ?? 0}`);
|
|
52
80
|
}
|
|
53
81
|
else {
|
|
54
82
|
respond("");
|
|
@@ -961,7 +961,7 @@ function buildBasicFuzzerExample() {
|
|
|
961
961
|
fuzz("basic string fuzzer", (value: string): bool => {
|
|
962
962
|
expect(value.length >= 0).toBe(true);
|
|
963
963
|
return value.length <= 24;
|
|
964
|
-
}).generate((seed: FuzzSeed, run: (value: string) => bool): void => {
|
|
964
|
+
}, 250).generate((seed: FuzzSeed, run: (value: string) => bool): void => {
|
|
965
965
|
run(
|
|
966
966
|
seed.string({
|
|
967
967
|
charset: "ascii",
|
package/bin/commands/run-core.js
CHANGED
|
@@ -10,6 +10,7 @@ import { buildWebRunnerSource } from "./web-runner-source.js";
|
|
|
10
10
|
import { createReporter as createDefaultReporter } from "../reporters/default.js";
|
|
11
11
|
import { createTapReporter } from "../reporters/tap.js";
|
|
12
12
|
import { persistCrashRecord } from "../crash-store.js";
|
|
13
|
+
import { describeCoveragePoint } from "../coverage-points.js";
|
|
13
14
|
const DEFAULT_CONFIG_PATH = path.join(process.cwd(), "./as-test.config.json");
|
|
14
15
|
class SnapshotStore {
|
|
15
16
|
constructor(specFile, snapshotDir, duplicateSpecBasenames = new Set()) {
|
|
@@ -1091,6 +1092,8 @@ function collectCoverageSummary(reports, enabled, showPoints, coverage) {
|
|
|
1091
1092
|
for (const point of report.coverage.points) {
|
|
1092
1093
|
if (isIgnoredCoverageFile(point.file, coverage))
|
|
1093
1094
|
continue;
|
|
1095
|
+
if (isIgnoredCoveragePoint(point, coverage))
|
|
1096
|
+
continue;
|
|
1094
1097
|
const key = `${point.file}::${point.hash}`;
|
|
1095
1098
|
const existing = uniquePoints.get(key);
|
|
1096
1099
|
if (!existing) {
|
|
@@ -1246,10 +1249,19 @@ function resolveCoverageOptions(raw) {
|
|
|
1246
1249
|
includeSpecs: false,
|
|
1247
1250
|
include: [],
|
|
1248
1251
|
exclude: [],
|
|
1252
|
+
ignore: {
|
|
1253
|
+
labels: [],
|
|
1254
|
+
names: [],
|
|
1255
|
+
locations: [],
|
|
1256
|
+
snippets: [],
|
|
1257
|
+
},
|
|
1249
1258
|
};
|
|
1250
1259
|
}
|
|
1251
1260
|
if (raw && typeof raw == "object") {
|
|
1252
1261
|
const obj = raw;
|
|
1262
|
+
const ignore = obj.ignore && typeof obj.ignore == "object" && !Array.isArray(obj.ignore)
|
|
1263
|
+
? obj.ignore
|
|
1264
|
+
: null;
|
|
1253
1265
|
return {
|
|
1254
1266
|
enabled: obj.enabled == null ? false : Boolean(obj.enabled),
|
|
1255
1267
|
includeSpecs: Boolean(obj.includeSpecs),
|
|
@@ -1259,6 +1271,20 @@ function resolveCoverageOptions(raw) {
|
|
|
1259
1271
|
exclude: Array.isArray(obj.exclude)
|
|
1260
1272
|
? obj.exclude.filter((item) => typeof item == "string")
|
|
1261
1273
|
: [],
|
|
1274
|
+
ignore: {
|
|
1275
|
+
labels: Array.isArray(ignore?.labels)
|
|
1276
|
+
? ignore.labels.filter((item) => typeof item == "string")
|
|
1277
|
+
: [],
|
|
1278
|
+
names: Array.isArray(ignore?.names)
|
|
1279
|
+
? ignore.names.filter((item) => typeof item == "string")
|
|
1280
|
+
: [],
|
|
1281
|
+
locations: Array.isArray(ignore?.locations)
|
|
1282
|
+
? ignore.locations.filter((item) => typeof item == "string")
|
|
1283
|
+
: [],
|
|
1284
|
+
snippets: Array.isArray(ignore?.snippets)
|
|
1285
|
+
? ignore.snippets.filter((item) => typeof item == "string")
|
|
1286
|
+
: [],
|
|
1287
|
+
},
|
|
1262
1288
|
};
|
|
1263
1289
|
}
|
|
1264
1290
|
return {
|
|
@@ -1266,8 +1292,49 @@ function resolveCoverageOptions(raw) {
|
|
|
1266
1292
|
includeSpecs: false,
|
|
1267
1293
|
include: [],
|
|
1268
1294
|
exclude: [],
|
|
1295
|
+
ignore: {
|
|
1296
|
+
labels: [],
|
|
1297
|
+
names: [],
|
|
1298
|
+
locations: [],
|
|
1299
|
+
snippets: [],
|
|
1300
|
+
},
|
|
1269
1301
|
};
|
|
1270
1302
|
}
|
|
1303
|
+
function isIgnoredCoveragePoint(point, coverage) {
|
|
1304
|
+
const ignore = coverage.ignore;
|
|
1305
|
+
if (!ignore.labels.length &&
|
|
1306
|
+
!ignore.names.length &&
|
|
1307
|
+
!ignore.locations.length &&
|
|
1308
|
+
!ignore.snippets.length) {
|
|
1309
|
+
return false;
|
|
1310
|
+
}
|
|
1311
|
+
const info = describeCoveragePoint(point.file, point.line, point.column, point.type);
|
|
1312
|
+
const location = `${point.file.replace(/\\/g, "/")}:${point.line}:${point.column}`;
|
|
1313
|
+
const label = info.displayType.toLowerCase();
|
|
1314
|
+
const name = info.subjectName?.toLowerCase() ?? "";
|
|
1315
|
+
const snippet = info.visible.toLowerCase();
|
|
1316
|
+
if (ignore.labels.some((pattern) => matchesCoverageTextPattern(label, pattern.toLowerCase()))) {
|
|
1317
|
+
return true;
|
|
1318
|
+
}
|
|
1319
|
+
if (name.length &&
|
|
1320
|
+
ignore.names.some((pattern) => matchesCoverageTextPattern(name, pattern.toLowerCase()))) {
|
|
1321
|
+
return true;
|
|
1322
|
+
}
|
|
1323
|
+
if (ignore.locations.some((pattern) => matchesCoverageTextPattern(location, pattern.replace(/\\/g, "/")))) {
|
|
1324
|
+
return true;
|
|
1325
|
+
}
|
|
1326
|
+
if (snippet.length &&
|
|
1327
|
+
ignore.snippets.some((pattern) => matchesCoverageTextPattern(snippet, pattern.toLowerCase()))) {
|
|
1328
|
+
return true;
|
|
1329
|
+
}
|
|
1330
|
+
return false;
|
|
1331
|
+
}
|
|
1332
|
+
function matchesCoverageTextPattern(value, pattern) {
|
|
1333
|
+
const normalized = pattern.trim();
|
|
1334
|
+
if (!normalized.length)
|
|
1335
|
+
return false;
|
|
1336
|
+
return globPatternToRegExp(normalized).test(value);
|
|
1337
|
+
}
|
|
1271
1338
|
function compareCoveragePoints(a, b) {
|
|
1272
1339
|
if (a.line !== b.line)
|
|
1273
1340
|
return a.line - b.line;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
const sourceLineCache = new Map();
|
|
4
|
+
export function describeCoveragePoint(file, line, column, fallbackType) {
|
|
5
|
+
const context = getCoverageSourceContext(file, line, column);
|
|
6
|
+
if (!context) {
|
|
7
|
+
return {
|
|
8
|
+
displayType: fallbackType,
|
|
9
|
+
subjectName: null,
|
|
10
|
+
visible: "",
|
|
11
|
+
focus: 0,
|
|
12
|
+
highlightStart: 0,
|
|
13
|
+
highlightEnd: 0,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const declaration = detectCoverageDeclaration(context.visible);
|
|
17
|
+
if (declaration) {
|
|
18
|
+
const [highlightStart, highlightEnd] = resolveCoverageHighlightSpan(context.visible, context.focus);
|
|
19
|
+
return {
|
|
20
|
+
displayType: declaration.type,
|
|
21
|
+
subjectName: declaration.name,
|
|
22
|
+
visible: context.visible,
|
|
23
|
+
focus: context.focus,
|
|
24
|
+
highlightStart,
|
|
25
|
+
highlightEnd,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const call = detectCoverageCall(context.visible, context.focus);
|
|
29
|
+
if (call) {
|
|
30
|
+
return {
|
|
31
|
+
displayType: "Call",
|
|
32
|
+
subjectName: call.name,
|
|
33
|
+
visible: context.visible,
|
|
34
|
+
focus: context.focus,
|
|
35
|
+
highlightStart: call.start,
|
|
36
|
+
highlightEnd: call.end,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const [highlightStart, highlightEnd] = resolveCoverageHighlightSpan(context.visible, context.focus);
|
|
40
|
+
return {
|
|
41
|
+
displayType: fallbackType,
|
|
42
|
+
subjectName: null,
|
|
43
|
+
visible: context.visible,
|
|
44
|
+
focus: context.focus,
|
|
45
|
+
highlightStart,
|
|
46
|
+
highlightEnd,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function readCoverageSourceLine(file, line) {
|
|
50
|
+
const resolved = path.resolve(process.cwd(), file);
|
|
51
|
+
let lines = sourceLineCache.get(resolved);
|
|
52
|
+
if (lines === undefined) {
|
|
53
|
+
try {
|
|
54
|
+
lines = readFileSync(resolved, "utf8").split(/\r?\n/);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
lines = null;
|
|
58
|
+
}
|
|
59
|
+
sourceLineCache.set(resolved, lines);
|
|
60
|
+
}
|
|
61
|
+
if (!lines)
|
|
62
|
+
return "";
|
|
63
|
+
return lines[line - 1] ?? "";
|
|
64
|
+
}
|
|
65
|
+
export function resolveCoverageHighlightSpan(visible, focus) {
|
|
66
|
+
if (!visible.length)
|
|
67
|
+
return [0, 0];
|
|
68
|
+
const index = Math.max(0, Math.min(visible.length - 1, focus));
|
|
69
|
+
if (isCoverageBoundary(visible.charAt(index))) {
|
|
70
|
+
return [index, Math.min(visible.length, index + 1)];
|
|
71
|
+
}
|
|
72
|
+
let start = index;
|
|
73
|
+
let end = index + 1;
|
|
74
|
+
while (start > 0 && !isCoverageBoundary(visible.charAt(start - 1)))
|
|
75
|
+
start--;
|
|
76
|
+
while (end < visible.length && !isCoverageBoundary(visible.charAt(end)))
|
|
77
|
+
end++;
|
|
78
|
+
return [start, end];
|
|
79
|
+
}
|
|
80
|
+
function getCoverageSourceContext(file, line, column) {
|
|
81
|
+
const sourceLine = readCoverageSourceLine(file, line);
|
|
82
|
+
if (!sourceLine)
|
|
83
|
+
return null;
|
|
84
|
+
const expanded = sourceLine.replace(/\t/g, " ");
|
|
85
|
+
const firstNonWhitespace = expanded.search(/\S/);
|
|
86
|
+
if (firstNonWhitespace == -1)
|
|
87
|
+
return null;
|
|
88
|
+
const visible = expanded.slice(firstNonWhitespace).trimEnd();
|
|
89
|
+
if (!visible.length)
|
|
90
|
+
return null;
|
|
91
|
+
const focus = Math.max(0, Math.min(visible.length - 1, Math.max(0, column - 1 - firstNonWhitespace)));
|
|
92
|
+
return { visible, focus };
|
|
93
|
+
}
|
|
94
|
+
function detectCoverageDeclaration(visible) {
|
|
95
|
+
const trimmed = visible.trim();
|
|
96
|
+
if (!trimmed.length)
|
|
97
|
+
return null;
|
|
98
|
+
let match = trimmed.match(/^(?:export\s+)?function\s+([A-Za-z_]\w*)(?:<[^>]+>)?\s*\(/);
|
|
99
|
+
if (match)
|
|
100
|
+
return { type: "Function", name: match[1] ?? null };
|
|
101
|
+
if (trimmed.startsWith("constructor(") ||
|
|
102
|
+
/^(?:public\s+|private\s+|protected\s+)constructor\s*\(/.test(trimmed)) {
|
|
103
|
+
return { type: "Constructor", name: "constructor" };
|
|
104
|
+
}
|
|
105
|
+
match = trimmed.match(/^(?:export\s+)?(?:public\s+|private\s+|protected\s+)?(?:static\s+)?([A-Za-z_]\w*)(?:<[^>]+>)?\([^)]*\)\s*:\s*[^{=]+[{]?$/);
|
|
106
|
+
if (match)
|
|
107
|
+
return { type: "Method", name: match[1] ?? null };
|
|
108
|
+
match = trimmed.match(/^(?:public\s+|private\s+|protected\s+)?(?:readonly\s+)?([A-Za-z_]\w*)(?:<[^>]+>)?\s*:\s*[^=;{]+(?:=.*)?;?$/);
|
|
109
|
+
if (match)
|
|
110
|
+
return { type: "Property", name: match[1] ?? null };
|
|
111
|
+
if (/^(?:export\s+)?class\b/.test(trimmed)) {
|
|
112
|
+
match = trimmed.match(/^(?:export\s+)?class\s+([A-Za-z_]\w*)/);
|
|
113
|
+
return { type: "Class", name: match?.[1] ?? null };
|
|
114
|
+
}
|
|
115
|
+
if (/^(?:export\s+)?enum\b/.test(trimmed)) {
|
|
116
|
+
match = trimmed.match(/^(?:export\s+)?enum\s+([A-Za-z_]\w*)/);
|
|
117
|
+
return { type: "Enum", name: match?.[1] ?? null };
|
|
118
|
+
}
|
|
119
|
+
if (/^(?:export\s+)?interface\b/.test(trimmed)) {
|
|
120
|
+
match = trimmed.match(/^(?:export\s+)?interface\s+([A-Za-z_]\w*)/);
|
|
121
|
+
return { type: "Interface", name: match?.[1] ?? null };
|
|
122
|
+
}
|
|
123
|
+
if (/^(?:export\s+)?namespace\b/.test(trimmed)) {
|
|
124
|
+
match = trimmed.match(/^(?:export\s+)?namespace\s+([A-Za-z_]\w*)/);
|
|
125
|
+
return { type: "Namespace", name: match?.[1] ?? null };
|
|
126
|
+
}
|
|
127
|
+
if (/^(?:const|let|var)\b/.test(trimmed)) {
|
|
128
|
+
match = trimmed.match(/^(?:const|let|var)\s+([A-Za-z_]\w*)/);
|
|
129
|
+
return { type: "Variable", name: match?.[1] ?? null };
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
function detectCoverageCall(visible, focus) {
|
|
134
|
+
const matches = [...visible.matchAll(/\b([A-Za-z_]\w*)(?:<[^>()]+>)?\s*\(/g)];
|
|
135
|
+
if (!matches.length)
|
|
136
|
+
return null;
|
|
137
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
138
|
+
let bestMatch = null;
|
|
139
|
+
for (const match of matches) {
|
|
140
|
+
const start = match.index ?? -1;
|
|
141
|
+
if (start == -1)
|
|
142
|
+
continue;
|
|
143
|
+
const end = start + match[0].length;
|
|
144
|
+
const distance = focus < start ? start - focus : focus >= end ? focus - end + 1 : 0;
|
|
145
|
+
if (distance < bestDistance) {
|
|
146
|
+
bestDistance = distance;
|
|
147
|
+
bestMatch = match;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!bestMatch)
|
|
151
|
+
return null;
|
|
152
|
+
const name = bestMatch[1] ?? null;
|
|
153
|
+
if (name == "if" ||
|
|
154
|
+
name == "for" ||
|
|
155
|
+
name == "while" ||
|
|
156
|
+
name == "switch" ||
|
|
157
|
+
name == "return" ||
|
|
158
|
+
name == "function") {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (bestDistance > Math.max(12, Math.floor(visible.length / 3))) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const start = bestMatch.index ?? 0;
|
|
165
|
+
return {
|
|
166
|
+
name,
|
|
167
|
+
start,
|
|
168
|
+
end: start + (name?.length ?? 1),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function isCoverageBoundary(ch) {
|
|
172
|
+
return /[\s()[\]{}.,;:+\-*/%&|^!?=<>]/.test(ch);
|
|
173
|
+
}
|
package/bin/index.js
CHANGED
|
@@ -281,7 +281,7 @@ function printCommandHelp(command) {
|
|
|
281
281
|
process.stdout.write(" --enable <feature> Enable feature (coverage|try-as)\n");
|
|
282
282
|
process.stdout.write(" --disable <feature> Disable feature (coverage|try-as)\n");
|
|
283
283
|
process.stdout.write(" --fuzz Run fuzz targets after the normal test pass\n");
|
|
284
|
-
process.stdout.write(" --fuzz-runs <
|
|
284
|
+
process.stdout.write(" --fuzz-runs <value> Override fuzz iteration count, e.g. 500, 1.5x, +10%, +100000\n");
|
|
285
285
|
process.stdout.write(" --fuzz-seed <n> Override fuzz seed for this run\n");
|
|
286
286
|
process.stdout.write(" --parallel Run files through an ordered worker pool using an automatic worker count\n");
|
|
287
287
|
process.stdout.write(" --jobs <n> Run files through an ordered worker pool\n");
|
|
@@ -302,7 +302,7 @@ function printCommandHelp(command) {
|
|
|
302
302
|
process.stdout.write(chalk.bold("Flags:\n"));
|
|
303
303
|
process.stdout.write(" --config <path> Use a specific config file\n");
|
|
304
304
|
process.stdout.write(" --mode <name[,name...]> Run one or multiple named config modes\n");
|
|
305
|
-
process.stdout.write(" --runs <
|
|
305
|
+
process.stdout.write(" --runs <value> Override fuzz iteration count, e.g. 500, 1.5x, +10%, +100000\n");
|
|
306
306
|
process.stdout.write(" --seed <n> Override fuzz seed\n");
|
|
307
307
|
process.stdout.write(" --jobs <n> Run files through an ordered worker pool\n");
|
|
308
308
|
process.stdout.write(" --build-jobs <n> Limit concurrent build tasks (defaults to --jobs)\n");
|
|
@@ -482,9 +482,10 @@ function resolveFuzzOverrides(rawArgs, command) {
|
|
|
482
482
|
runs: "--fuzz-runs",
|
|
483
483
|
seed: "--fuzz-seed",
|
|
484
484
|
};
|
|
485
|
-
const runs =
|
|
485
|
+
const runs = parseFuzzRunsFlag(rawArgs, i, direct.runs);
|
|
486
486
|
if (runs) {
|
|
487
|
-
out.runs = runs.
|
|
487
|
+
out.runs = runs.absoluteRuns;
|
|
488
|
+
out.runsOverride = runs.override;
|
|
488
489
|
if (runs.consumeNext)
|
|
489
490
|
i++;
|
|
490
491
|
continue;
|
|
@@ -499,6 +500,63 @@ function resolveFuzzOverrides(rawArgs, command) {
|
|
|
499
500
|
}
|
|
500
501
|
return out;
|
|
501
502
|
}
|
|
503
|
+
function parseFuzzRunsFlag(rawArgs, index, flag) {
|
|
504
|
+
const arg = rawArgs[index];
|
|
505
|
+
let value = "";
|
|
506
|
+
let consumeNext = false;
|
|
507
|
+
if (arg == flag) {
|
|
508
|
+
const next = rawArgs[index + 1];
|
|
509
|
+
if (!next || !next.length) {
|
|
510
|
+
throw new Error(`${flag} requires a value such as 500, 1.5x, +10%, or +100000`);
|
|
511
|
+
}
|
|
512
|
+
value = next;
|
|
513
|
+
consumeNext = true;
|
|
514
|
+
}
|
|
515
|
+
else if (arg.startsWith(`${flag}=`)) {
|
|
516
|
+
value = arg.slice(flag.length + 1);
|
|
517
|
+
if (!value.length) {
|
|
518
|
+
throw new Error(`${flag} requires a value such as 500, 1.5x, +10%, or +100000`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
const parsed = parseFuzzRunsValue(flag, value.trim());
|
|
525
|
+
return {
|
|
526
|
+
key: flag,
|
|
527
|
+
absoluteRuns: parsed.kind == "set" ? parsed.value : undefined,
|
|
528
|
+
override: parsed,
|
|
529
|
+
consumeNext,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
function parseFuzzRunsValue(flag, value) {
|
|
533
|
+
if (/^\d+$/.test(value)) {
|
|
534
|
+
const parsed = parseIntegerFlag(flag, value);
|
|
535
|
+
return { kind: "set", value: parsed };
|
|
536
|
+
}
|
|
537
|
+
if (/^[+-]\d+$/.test(value)) {
|
|
538
|
+
const delta = Number(value);
|
|
539
|
+
if (!Number.isFinite(delta) || !Number.isInteger(delta)) {
|
|
540
|
+
throw new Error(`${flag} additive run override must be an integer`);
|
|
541
|
+
}
|
|
542
|
+
return { kind: "add", value: delta };
|
|
543
|
+
}
|
|
544
|
+
if (/^\d+(?:\.\d+)?x$/i.test(value)) {
|
|
545
|
+
const factor = Number(value.slice(0, -1));
|
|
546
|
+
if (!Number.isFinite(factor) || factor <= 0) {
|
|
547
|
+
throw new Error(`${flag} multiplier must be greater than 0`);
|
|
548
|
+
}
|
|
549
|
+
return { kind: "scale", value: factor };
|
|
550
|
+
}
|
|
551
|
+
if (/^[+-]\d+(?:\.\d+)?%$/.test(value)) {
|
|
552
|
+
const percent = Number(value.slice(0, -1));
|
|
553
|
+
if (!Number.isFinite(percent)) {
|
|
554
|
+
throw new Error(`${flag} percentage must be numeric`);
|
|
555
|
+
}
|
|
556
|
+
return { kind: "percent-add", value: percent };
|
|
557
|
+
}
|
|
558
|
+
throw new Error(`${flag} must be a run count or expression such as 500, 1.5x, +10%, or +100000`);
|
|
559
|
+
}
|
|
502
560
|
function resolveListFlags(rawArgs, command) {
|
|
503
561
|
const out = {
|
|
504
562
|
list: false,
|
package/bin/reporters/default.js
CHANGED
|
@@ -3,6 +3,7 @@ import { diff } from "typer-diff";
|
|
|
3
3
|
import { readFileSync } from "fs";
|
|
4
4
|
import * as path from "path";
|
|
5
5
|
import { formatTime } from "../util.js";
|
|
6
|
+
import { describeCoveragePoint, readCoverageSourceLine, resolveCoverageHighlightSpan, } from "../coverage-points.js";
|
|
6
7
|
export const createReporter = (context) => {
|
|
7
8
|
return new DefaultReporter(context);
|
|
8
9
|
};
|
|
@@ -633,21 +634,60 @@ function renderSummaryLine(label, summary, layout = {
|
|
|
633
634
|
process.stdout.write(totalText.padStart(layout.totalWidth) + "\n");
|
|
634
635
|
}
|
|
635
636
|
function renderCoverageSummary(summary) {
|
|
637
|
+
console.log("");
|
|
638
|
+
console.log(chalk.bold("Coverage"));
|
|
639
|
+
if (!summary.files.length || summary.total <= 0) {
|
|
640
|
+
console.log(` ${chalk.dim("No eligible source files were tracked for coverage.")}`);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
636
643
|
const pct = summary.total
|
|
637
644
|
? ((summary.covered * 100) / summary.total).toFixed(2)
|
|
638
645
|
: "100.00";
|
|
646
|
+
const missingLabel = summary.uncovered == 1 ? "1 point missing" : `${summary.uncovered} points missing`;
|
|
647
|
+
const fileLabel = summary.files.length == 1 ? "1 file" : `${summary.files.length} files`;
|
|
639
648
|
const color = Number(pct) >= 90
|
|
640
649
|
? chalk.greenBright
|
|
641
650
|
: Number(pct) >= 75
|
|
642
651
|
? chalk.yellowBright
|
|
643
652
|
: chalk.redBright;
|
|
644
|
-
console.log("");
|
|
645
|
-
|
|
653
|
+
console.log(` ${color(pct + "%")} ${renderCoverageBar(summary.percent)} ${chalk.dim(`(${summary.covered}/${summary.total} covered, ${missingLabel}, ${fileLabel})`)}`);
|
|
654
|
+
const ranked = [...summary.files].sort((a, b) => {
|
|
655
|
+
if (a.percent != b.percent)
|
|
656
|
+
return a.percent - b.percent;
|
|
657
|
+
if (a.uncovered != b.uncovered)
|
|
658
|
+
return b.uncovered - a.uncovered;
|
|
659
|
+
return a.file.localeCompare(b.file);
|
|
660
|
+
});
|
|
661
|
+
console.log(chalk.bold(" File Breakdown"));
|
|
662
|
+
for (const file of ranked.slice(0, 8)) {
|
|
663
|
+
const filePct = file.total
|
|
664
|
+
? ((file.covered * 100) / file.total).toFixed(2)
|
|
665
|
+
: "100.00";
|
|
666
|
+
const fileColor = Number(filePct) >= 90
|
|
667
|
+
? chalk.greenBright
|
|
668
|
+
: Number(filePct) >= 75
|
|
669
|
+
? chalk.yellowBright
|
|
670
|
+
: chalk.redBright;
|
|
671
|
+
const suffix = file.uncovered > 0
|
|
672
|
+
? `${file.uncovered} missing`
|
|
673
|
+
: "fully covered";
|
|
674
|
+
console.log(` ${fileColor(filePct.padStart(6) + "%")} ${toRelativeResultPath(file.file).padEnd(36)} ${chalk.dim(`${file.covered}/${file.total} covered, ${suffix}`)}`);
|
|
675
|
+
}
|
|
676
|
+
if (ranked.length > 8) {
|
|
677
|
+
console.log(chalk.dim(` ... ${ranked.length - 8} more files`));
|
|
678
|
+
}
|
|
646
679
|
}
|
|
647
680
|
function renderCoveragePoints(files) {
|
|
648
681
|
console.log("");
|
|
649
|
-
console.log(chalk.bold("Coverage
|
|
682
|
+
console.log(chalk.bold("Coverage Gaps"));
|
|
650
683
|
const sortedFiles = [...files].sort((a, b) => a.file.localeCompare(b.file));
|
|
684
|
+
const missingPoints = sortedFiles.flatMap((file) => file.points
|
|
685
|
+
.filter((point) => !point.executed)
|
|
686
|
+
.map((point) => ({
|
|
687
|
+
...point,
|
|
688
|
+
displayType: describeCoveragePoint(point.file, point.line, point.column, point.type).displayType,
|
|
689
|
+
})));
|
|
690
|
+
const layout = createCoverageGapLayout(missingPoints);
|
|
651
691
|
for (const file of sortedFiles) {
|
|
652
692
|
const points = [...file.points].sort((a, b) => {
|
|
653
693
|
if (a.line != b.line)
|
|
@@ -656,10 +696,69 @@ function renderCoveragePoints(files) {
|
|
|
656
696
|
return a.column - b.column;
|
|
657
697
|
return a.type.localeCompare(b.type);
|
|
658
698
|
});
|
|
699
|
+
const missing = points.filter((point) => !point.executed);
|
|
700
|
+
if (!missing.length)
|
|
701
|
+
continue;
|
|
702
|
+
console.log(` ${chalk.bold(toRelativeResultPath(file.file))} ${chalk.dim(`(${missing.length} uncovered)`)}`);
|
|
659
703
|
for (const point of points) {
|
|
660
704
|
if (point.executed)
|
|
661
705
|
continue;
|
|
662
|
-
|
|
706
|
+
const location = `${toRelativeResultPath(point.file)}:${point.line}:${point.column}`;
|
|
707
|
+
const snippet = formatCoverageSnippet(point.file, point.line, point.column);
|
|
708
|
+
const typeLabel = describeCoveragePoint(point.file, point.line, point.column, point.type).displayType.padEnd(layout.typeWidth + 4);
|
|
709
|
+
const locationLabel = location.padEnd(layout.locationWidth + 4);
|
|
710
|
+
console.log(` ${chalk.red("x")} ${chalk.dim(typeLabel)}${chalk.dim(locationLabel)}${snippet}`);
|
|
663
711
|
}
|
|
664
712
|
}
|
|
665
713
|
}
|
|
714
|
+
function renderCoverageBar(percent) {
|
|
715
|
+
const slots = 12;
|
|
716
|
+
const filled = Math.max(0, Math.min(slots, Math.round((Math.max(0, Math.min(100, percent)) / 100) * slots)));
|
|
717
|
+
return `[${"=".repeat(filled)}${"-".repeat(slots - filled)}]`;
|
|
718
|
+
}
|
|
719
|
+
function createCoverageGapLayout(points) {
|
|
720
|
+
return {
|
|
721
|
+
typeWidth: Math.max(...points.map((point) => point.displayType.length), 5),
|
|
722
|
+
locationWidth: Math.max(...points.map((point) => `${toRelativeResultPath(point.file)}:${point.line}:${point.column}`.length), 1),
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
function formatCoverageSnippet(file, line, column) {
|
|
726
|
+
const sourceLine = readCoverageSourceLine(file, line);
|
|
727
|
+
if (!sourceLine)
|
|
728
|
+
return "";
|
|
729
|
+
const expanded = sourceLine.replace(/\t/g, " ");
|
|
730
|
+
const firstNonWhitespace = expanded.search(/\S/);
|
|
731
|
+
if (firstNonWhitespace == -1)
|
|
732
|
+
return "";
|
|
733
|
+
const visible = expanded.slice(firstNonWhitespace).trimEnd();
|
|
734
|
+
if (!visible.length)
|
|
735
|
+
return "";
|
|
736
|
+
const maxWidth = 72;
|
|
737
|
+
const focus = Math.max(0, Math.min(visible.length - 1, Math.max(0, column - 1 - firstNonWhitespace)));
|
|
738
|
+
if (visible.length <= maxWidth) {
|
|
739
|
+
return styleCoverageSnippetWindow(visible, 0, visible.length, focus);
|
|
740
|
+
}
|
|
741
|
+
const start = Math.max(0, Math.min(visible.length - maxWidth, focus - Math.floor(maxWidth / 2)));
|
|
742
|
+
const end = Math.min(visible.length, start + maxWidth);
|
|
743
|
+
return styleCoverageSnippetWindow(visible, start, end, focus);
|
|
744
|
+
}
|
|
745
|
+
function styleCoverageSnippetWindow(visible, start, end, focus) {
|
|
746
|
+
const prefix = start > 0 ? "..." : "";
|
|
747
|
+
const suffix = end < visible.length ? "..." : "";
|
|
748
|
+
const slice = visible.slice(start, end);
|
|
749
|
+
const localFocus = Math.max(0, Math.min(slice.length - 1, focus - start));
|
|
750
|
+
const [highlightStart, highlightEnd] = resolveCoverageHighlightSpan(visible, focus);
|
|
751
|
+
const localStart = Math.max(0, Math.min(slice.length, highlightStart - start));
|
|
752
|
+
const localEnd = Math.max(localStart + 1, Math.min(slice.length, highlightEnd - start));
|
|
753
|
+
if (!slice.length)
|
|
754
|
+
return "";
|
|
755
|
+
if (localStart >= slice.length) {
|
|
756
|
+
return chalk.dim(`${prefix}${slice}${suffix}`);
|
|
757
|
+
}
|
|
758
|
+
const head = slice.slice(0, localStart);
|
|
759
|
+
const body = slice.slice(localStart, localEnd || localStart + 1);
|
|
760
|
+
const tail = slice.slice(localEnd || localStart + 1);
|
|
761
|
+
return (chalk.dim(prefix + head) +
|
|
762
|
+
chalk.dim.underline(body.length ? body : slice.charAt(localFocus)) +
|
|
763
|
+
chalk.dim(tail + suffix));
|
|
764
|
+
}
|
package/bin/types.js
CHANGED
|
@@ -21,6 +21,15 @@ export class CoverageOptions {
|
|
|
21
21
|
this.includeSpecs = false;
|
|
22
22
|
this.include = [];
|
|
23
23
|
this.exclude = [];
|
|
24
|
+
this.ignore = new CoverageIgnoreOptions();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export class CoverageIgnoreOptions {
|
|
28
|
+
constructor() {
|
|
29
|
+
this.labels = [];
|
|
30
|
+
this.names = [];
|
|
31
|
+
this.locations = [];
|
|
32
|
+
this.snippets = [];
|
|
24
33
|
}
|
|
25
34
|
}
|
|
26
35
|
export class Suite {
|
package/bin/util.js
CHANGED
|
@@ -340,6 +340,22 @@ function validateCoverageValue(value, path, issues) {
|
|
|
340
340
|
}
|
|
341
341
|
validateStringArrayField(obj, "include", path, issues);
|
|
342
342
|
validateStringArrayField(obj, "exclude", path, issues);
|
|
343
|
+
if ("ignore" in obj && obj.ignore != undefined) {
|
|
344
|
+
if (!obj.ignore || typeof obj.ignore != "object" || Array.isArray(obj.ignore)) {
|
|
345
|
+
issues.push({
|
|
346
|
+
path: `${path}.ignore`,
|
|
347
|
+
message: "must be an object",
|
|
348
|
+
fix: 'set "ignore" to an object such as { "labels": ["Call"], "names": ["panic"] }',
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
const ignore = obj.ignore;
|
|
353
|
+
validateStringArrayField(ignore, "labels", `${path}.ignore`, issues);
|
|
354
|
+
validateStringArrayField(ignore, "names", `${path}.ignore`, issues);
|
|
355
|
+
validateStringArrayField(ignore, "locations", `${path}.ignore`, issues);
|
|
356
|
+
validateStringArrayField(ignore, "snippets", `${path}.ignore`, issues);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
343
359
|
}
|
|
344
360
|
function validateStringArrayField(raw, key, pathPrefix, issues) {
|
|
345
361
|
if (!(key in raw) || raw[key] == undefined)
|