as-test 1.3.0 → 1.4.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/CHANGELOG.md +41 -0
- package/README.md +1 -4
- package/assembly/index.ts +66 -47
- package/assembly/src/expectation.ts +44 -86
- package/assembly/src/fuzz.ts +10 -10
- package/assembly/src/log.ts +3 -3
- package/assembly/src/reflect.ts +122 -0
- package/assembly/src/stringify.ts +240 -0
- package/assembly/src/suite.ts +48 -27
- package/assembly/src/tests.ts +7 -7
- package/assembly/util/wipc.ts +2 -2
- package/bin/build-worker-pool.js +9 -0
- package/bin/build-worker.js +27 -3
- package/bin/commands/build-core.js +144 -82
- package/bin/commands/init-core.js +0 -3
- package/bin/commands/run-core.js +165 -41
- package/bin/commands/run.js +2 -1
- package/bin/commands/test.js +2 -1
- package/bin/dependency-graph.js +0 -0
- package/bin/index.js +534 -79
- package/bin/reporters/default.js +34 -0
- package/bin/util.js +9 -0
- package/package.json +3 -7
- package/transform/lib/equals.js +388 -0
- package/transform/lib/index.js +2 -0
- package/transform/lib/log.js +3 -7
- package/transform/lib/types.js +4 -2
- package/transform/lib/transform.js +0 -502
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync } from "fs";
|
|
2
|
+
import { promises as fsPromises } from "fs";
|
|
3
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2
4
|
import { INTERNAL_FEATURE_NAMES, normalizeFeatureName } from "../types.js";
|
|
3
5
|
import { glob } from "glob";
|
|
4
6
|
import chalk from "chalk";
|
|
@@ -6,6 +8,7 @@ import { spawn } from "child_process";
|
|
|
6
8
|
import * as path from "path";
|
|
7
9
|
import {
|
|
8
10
|
createMemoryStream,
|
|
11
|
+
libraryFiles as ascLibraryFiles,
|
|
9
12
|
main as ascMain,
|
|
10
13
|
} from "assemblyscript/dist/asc.js";
|
|
11
14
|
import {
|
|
@@ -20,6 +23,7 @@ import {
|
|
|
20
23
|
import { persistCrashRecord } from "../crash-store.js";
|
|
21
24
|
import { BuildWorkerPool } from "../build-worker-pool.js";
|
|
22
25
|
const DEFAULT_CONFIG_PATH = path.join(process.cwd(), "./as-test.config.json");
|
|
26
|
+
export const buildRecorderStorage = new AsyncLocalStorage();
|
|
23
27
|
export class BuildFailureError extends Error {
|
|
24
28
|
constructor(args) {
|
|
25
29
|
super(args.message);
|
|
@@ -82,7 +86,8 @@ export async function build(
|
|
|
82
86
|
if (
|
|
83
87
|
!resolvedConfig &&
|
|
84
88
|
!process.env.AS_TEST_BUILD_API &&
|
|
85
|
-
!hasCustomBuildCommand(config)
|
|
89
|
+
!hasCustomBuildCommand(config) &&
|
|
90
|
+
!buildRecorderStorage.getStore()
|
|
86
91
|
) {
|
|
87
92
|
const pool = getSerialBuildWorkerPool();
|
|
88
93
|
for (const file of inputFiles) {
|
|
@@ -427,9 +432,25 @@ function getBuildCommand(
|
|
|
427
432
|
args: [...tokens.slice(1), ...userArgs],
|
|
428
433
|
};
|
|
429
434
|
}
|
|
430
|
-
const
|
|
435
|
+
const tryAsAlreadyConfigured =
|
|
436
|
+
argsDeclareTryAs(userArgs) || asconfigDeclaresTryAs(config.config);
|
|
437
|
+
const defaultArgs = getDefaultBuildArgs(
|
|
438
|
+
config,
|
|
439
|
+
featureToggles,
|
|
440
|
+
tryAsAlreadyConfigured,
|
|
441
|
+
);
|
|
431
442
|
const ascInvocation = resolveAscInvocation(pkgRunner);
|
|
432
|
-
|
|
443
|
+
// as-test's own transform goes first so CoverageTransform sees the
|
|
444
|
+
// unmodified user AST. User-supplied `--transform` flags follow it,
|
|
445
|
+
// then the rest of as-test's default args (config, features, etc.).
|
|
446
|
+
const args = [
|
|
447
|
+
...ascInvocation.args,
|
|
448
|
+
file,
|
|
449
|
+
"--transform",
|
|
450
|
+
"as-test/transform",
|
|
451
|
+
...userArgs,
|
|
452
|
+
...defaultArgs,
|
|
453
|
+
];
|
|
433
454
|
if (config.outDir.length) {
|
|
434
455
|
args.push("-o", outFile);
|
|
435
456
|
}
|
|
@@ -578,7 +599,12 @@ function ensureDeps(config) {
|
|
|
578
599
|
}
|
|
579
600
|
}
|
|
580
601
|
async function buildFile(invocation, env) {
|
|
581
|
-
|
|
602
|
+
// The readFile hook only works through the API path. If the watch recorder
|
|
603
|
+
// is active but env wasn't already set, force the API path so we can
|
|
604
|
+
// deliver the read stream.
|
|
605
|
+
const recorderActive = !!buildRecorderStorage.getStore();
|
|
606
|
+
const wantsApi = recorderActive || process.env.AS_TEST_BUILD_API == "1";
|
|
607
|
+
if (wantsApi && invocation.apiArgs?.length) {
|
|
582
608
|
await buildFileViaApi(invocation.apiArgs, env);
|
|
583
609
|
return;
|
|
584
610
|
}
|
|
@@ -599,8 +625,28 @@ async function buildFileViaApi(args, env) {
|
|
|
599
625
|
});
|
|
600
626
|
const previousEnv = snapshotEnv();
|
|
601
627
|
applyEnv(env);
|
|
628
|
+
// asc's `libraryFiles` is a module-global dict that `--lib` flags mutate
|
|
629
|
+
// by inserting new entries (e.g. wasi-shim files when targeting wasi).
|
|
630
|
+
// When we call ascMain in-process across multiple modes (which the watch
|
|
631
|
+
// loop does), those entries leak into later compiles and try to resolve
|
|
632
|
+
// imports that the next mode's lib path doesn't satisfy. Snapshot the
|
|
633
|
+
// keys before each call and drop anything new after, so each ascMain sees
|
|
634
|
+
// the same baseline stdlib.
|
|
635
|
+
const baselineLibraryKeys = new Set(Object.keys(ascLibraryFiles));
|
|
602
636
|
try {
|
|
603
|
-
const
|
|
637
|
+
const ascOptions = { stdout, stderr };
|
|
638
|
+
const recorder = buildRecorderStorage.getStore();
|
|
639
|
+
if (recorder) {
|
|
640
|
+
const specFile = args[0] ? path.resolve(args[0]) : "";
|
|
641
|
+
const modeName = process.env.AS_TEST_MODE_NAME;
|
|
642
|
+
const mode = modeName && modeName !== "default" ? modeName : undefined;
|
|
643
|
+
if (specFile) {
|
|
644
|
+
ascOptions.readFile = makeRecordingReadFile((abs) => {
|
|
645
|
+
recorder.record(mode, specFile, abs);
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const result = await ascMain(args, ascOptions);
|
|
604
650
|
if (result.error) {
|
|
605
651
|
const error = result.error;
|
|
606
652
|
error.stderr = stderrChunks.join("").trim();
|
|
@@ -609,8 +655,27 @@ async function buildFileViaApi(args, env) {
|
|
|
609
655
|
}
|
|
610
656
|
} finally {
|
|
611
657
|
restoreEnv(previousEnv);
|
|
658
|
+
for (const key of Object.keys(ascLibraryFiles)) {
|
|
659
|
+
if (!baselineLibraryKeys.has(key)) {
|
|
660
|
+
delete ascLibraryFiles[key];
|
|
661
|
+
}
|
|
662
|
+
}
|
|
612
663
|
}
|
|
613
664
|
}
|
|
665
|
+
// Mirrors asc's own default readFile (path.resolve(baseDir, filename),
|
|
666
|
+
// readFile utf-8, return null on ENOENT) and records each successful read.
|
|
667
|
+
function makeRecordingReadFile(onFileRead) {
|
|
668
|
+
return async (filename, baseDir) => {
|
|
669
|
+
const resolved = path.resolve(baseDir, filename);
|
|
670
|
+
try {
|
|
671
|
+
const content = await fsPromises.readFile(resolved, "utf8");
|
|
672
|
+
onFileRead(resolved);
|
|
673
|
+
return content;
|
|
674
|
+
} catch {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
}
|
|
614
679
|
async function buildFileViaSpawn(invocation, env) {
|
|
615
680
|
await new Promise((resolve, reject) => {
|
|
616
681
|
const child = spawn(invocation.command, invocation.args, {
|
|
@@ -675,6 +740,7 @@ function formatInvocation(invocation) {
|
|
|
675
740
|
.join(" ");
|
|
676
741
|
}
|
|
677
742
|
export { getBuildCommand, formatInvocation };
|
|
743
|
+
export { argsDeclareTryAs, asconfigDeclaresTryAs };
|
|
678
744
|
function getBuildStderr(error) {
|
|
679
745
|
const err = error;
|
|
680
746
|
const stderr = err?.stderr;
|
|
@@ -695,18 +761,24 @@ function getBuildStdout(error) {
|
|
|
695
761
|
if (stdout instanceof Buffer) return stdout.toString("utf8").trim();
|
|
696
762
|
return "";
|
|
697
763
|
}
|
|
698
|
-
function getDefaultBuildArgs(
|
|
764
|
+
function getDefaultBuildArgs(
|
|
765
|
+
config,
|
|
766
|
+
featureToggles,
|
|
767
|
+
tryAsAlreadyConfigured = false,
|
|
768
|
+
) {
|
|
699
769
|
const buildArgs = [];
|
|
700
770
|
const effectiveFeatures = resolveEffectiveFeatures(config, featureToggles);
|
|
701
771
|
const tryAsEnabled = resolveTryAsEnabled(effectiveFeatures.has("try-as"));
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
772
|
+
// `--transform as-test/transform` is appended by `getBuildCommand` at
|
|
773
|
+
// the front of the user-supplied transforms so coverage instruments
|
|
774
|
+
// the unmodified user AST.
|
|
775
|
+
// Auto-inject `--transform try-as/transform` when the `try-as`
|
|
776
|
+
// feature is on. Unlike json-as, try-as is tightly coupled to as-test
|
|
777
|
+
// (it powers `toThrow()`), only meaningful when the user explicitly
|
|
778
|
+
// opts into the feature, and doesn't rewrite arbitrary user code in
|
|
779
|
+
// ways that surprise consumers — auto-injection keeps the feature
|
|
780
|
+
// ergonomics intact without the conflict surface json-as exposed.
|
|
781
|
+
if (tryAsEnabled && !tryAsAlreadyConfigured) {
|
|
710
782
|
buildArgs.push("--transform", "try-as/transform");
|
|
711
783
|
}
|
|
712
784
|
if (config.config && config.config !== "none") {
|
|
@@ -749,74 +821,6 @@ function getDefaultBuildArgs(config, featureToggles) {
|
|
|
749
821
|
}
|
|
750
822
|
return buildArgs;
|
|
751
823
|
}
|
|
752
|
-
// Treats anything whose path contains a "json-as" path component as a
|
|
753
|
-
// user-supplied json-as transform. Matches bare specifiers, subpath specifiers,
|
|
754
|
-
// absolute paths, and ./node_modules paths.
|
|
755
|
-
const JSON_AS_TRANSFORM_PATTERN = /(?:^|[\\/])json-as(?:[\\/@]|$)/;
|
|
756
|
-
function userSuppliesJsonAsTransform(config) {
|
|
757
|
-
for (const raw of config.buildOptions.args) {
|
|
758
|
-
if (!raw.length) continue;
|
|
759
|
-
const tokens = tokenizeCommand(raw);
|
|
760
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
761
|
-
const token = tokens[i];
|
|
762
|
-
if (token == "--transform") {
|
|
763
|
-
const value = tokens[i + 1];
|
|
764
|
-
if (value && JSON_AS_TRANSFORM_PATTERN.test(value)) return true;
|
|
765
|
-
} else if (token.startsWith("--transform=")) {
|
|
766
|
-
const value = token.slice("--transform=".length);
|
|
767
|
-
if (value && JSON_AS_TRANSFORM_PATTERN.test(value)) return true;
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
if (config.config && config.config !== "none" && existsSync(config.config)) {
|
|
772
|
-
if (asconfigDeclaresJsonAs(config.config, new Set())) return true;
|
|
773
|
-
}
|
|
774
|
-
return false;
|
|
775
|
-
}
|
|
776
|
-
function asconfigDeclaresJsonAs(configFile, seen) {
|
|
777
|
-
const resolved = path.resolve(configFile);
|
|
778
|
-
if (seen.has(resolved)) return false;
|
|
779
|
-
seen.add(resolved);
|
|
780
|
-
let parsed;
|
|
781
|
-
try {
|
|
782
|
-
parsed = JSON.parse(readFileSync(resolved, "utf8"));
|
|
783
|
-
} catch {
|
|
784
|
-
return false;
|
|
785
|
-
}
|
|
786
|
-
if (!parsed || typeof parsed != "object") return false;
|
|
787
|
-
const obj = parsed;
|
|
788
|
-
if (transformsContainJsonAs(obj.options)) return true;
|
|
789
|
-
if (transformsContainJsonAs(obj)) return true;
|
|
790
|
-
const targets = obj.targets;
|
|
791
|
-
if (targets && typeof targets == "object" && !Array.isArray(targets)) {
|
|
792
|
-
for (const value of Object.values(targets)) {
|
|
793
|
-
if (transformsContainJsonAs(value)) return true;
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
const extendsValue = obj.extends;
|
|
797
|
-
if (typeof extendsValue == "string" && extendsValue.length) {
|
|
798
|
-
const parentPath = path.resolve(path.dirname(resolved), extendsValue);
|
|
799
|
-
if (existsSync(parentPath) && asconfigDeclaresJsonAs(parentPath, seen)) {
|
|
800
|
-
return true;
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
return false;
|
|
804
|
-
}
|
|
805
|
-
function transformsContainJsonAs(value) {
|
|
806
|
-
if (!value || typeof value != "object") return false;
|
|
807
|
-
const transform = value.transform;
|
|
808
|
-
if (typeof transform == "string") {
|
|
809
|
-
return JSON_AS_TRANSFORM_PATTERN.test(transform);
|
|
810
|
-
}
|
|
811
|
-
if (Array.isArray(transform)) {
|
|
812
|
-
for (const item of transform) {
|
|
813
|
-
if (typeof item == "string" && JSON_AS_TRANSFORM_PATTERN.test(item)) {
|
|
814
|
-
return true;
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
return false;
|
|
819
|
-
}
|
|
820
824
|
function resolveEffectiveFeatures(config, featureToggles) {
|
|
821
825
|
const effective = new Set();
|
|
822
826
|
for (const name of config.features) {
|
|
@@ -853,6 +857,64 @@ function resolveCoverageEnabled(rawCoverage, override) {
|
|
|
853
857
|
function hasTryAsRuntime() {
|
|
854
858
|
return resolveProjectModule("try-as/package.json") != null;
|
|
855
859
|
}
|
|
860
|
+
const TRY_AS_TRANSFORM_RE = /(?:^|[\\/])try-as(?:[\\/]|$)/;
|
|
861
|
+
function isTryAsTransformSpec(value) {
|
|
862
|
+
if (typeof value !== "string") return false;
|
|
863
|
+
if (value === "try-as") return true;
|
|
864
|
+
return TRY_AS_TRANSFORM_RE.test(value);
|
|
865
|
+
}
|
|
866
|
+
function argsDeclareTryAs(args) {
|
|
867
|
+
for (let i = 0; i < args.length; i++) {
|
|
868
|
+
const arg = args[i];
|
|
869
|
+
if (arg === "--transform" || arg === "-t") {
|
|
870
|
+
const next = args[i + 1];
|
|
871
|
+
if (isTryAsTransformSpec(next)) return true;
|
|
872
|
+
} else if (arg.startsWith("--transform=")) {
|
|
873
|
+
if (isTryAsTransformSpec(arg.slice("--transform=".length))) return true;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
878
|
+
function asconfigDeclaresTryAs(configPath, seen = new Set()) {
|
|
879
|
+
if (!configPath || configPath === "none") return false;
|
|
880
|
+
const resolved = path.isAbsolute(configPath)
|
|
881
|
+
? configPath
|
|
882
|
+
: path.resolve(process.cwd(), configPath);
|
|
883
|
+
if (seen.has(resolved)) return false;
|
|
884
|
+
seen.add(resolved);
|
|
885
|
+
if (!existsSync(resolved)) return false;
|
|
886
|
+
let parsed;
|
|
887
|
+
try {
|
|
888
|
+
parsed = JSON.parse(readFileSync(resolved, "utf8"));
|
|
889
|
+
} catch {
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
892
|
+
if (!parsed || typeof parsed !== "object") return false;
|
|
893
|
+
const obj = parsed;
|
|
894
|
+
const options = obj.options;
|
|
895
|
+
if (options && typeof options === "object") {
|
|
896
|
+
const transform = options.transform;
|
|
897
|
+
if (Array.isArray(transform)) {
|
|
898
|
+
for (const t of transform) {
|
|
899
|
+
if (isTryAsTransformSpec(t)) return true;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
const extendsField = obj.extends;
|
|
904
|
+
const extendsList = Array.isArray(extendsField)
|
|
905
|
+
? extendsField
|
|
906
|
+
: typeof extendsField === "string"
|
|
907
|
+
? [extendsField]
|
|
908
|
+
: [];
|
|
909
|
+
for (const ext of extendsList) {
|
|
910
|
+
if (typeof ext !== "string") continue;
|
|
911
|
+
const extPath = path.isAbsolute(ext)
|
|
912
|
+
? ext
|
|
913
|
+
: path.resolve(path.dirname(resolved), ext);
|
|
914
|
+
if (asconfigDeclaresTryAs(extPath, seen)) return true;
|
|
915
|
+
}
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
856
918
|
function resolveWasiShim() {
|
|
857
919
|
const resolved = resolveProjectModule(
|
|
858
920
|
"@assemblyscript/wasi-shim/asconfig.json",
|
|
@@ -745,9 +745,6 @@ function applyInit(root, target, example, fuzzExample, features, force) {
|
|
|
745
745
|
if (!devDependencies["as-test"]) {
|
|
746
746
|
devDependencies["as-test"] = "^" + getCliVersion();
|
|
747
747
|
}
|
|
748
|
-
if (!hasDependency(pkg, "json-as")) {
|
|
749
|
-
devDependencies["json-as"] = "^1.3.5";
|
|
750
|
-
}
|
|
751
748
|
if (!hasDependency(pkg, "assemblyscript")) {
|
|
752
749
|
devDependencies["assemblyscript"] = "^0.28.9";
|
|
753
750
|
}
|
package/bin/commands/run-core.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getExec,
|
|
10
10
|
loadConfig,
|
|
11
11
|
resolveArtifactPath,
|
|
12
|
+
resolveSnapshotPath,
|
|
12
13
|
resolveSpecRelativePath,
|
|
13
14
|
tokenizeCommand,
|
|
14
15
|
} from "../util.js";
|
|
@@ -33,12 +34,7 @@ class SnapshotStore {
|
|
|
33
34
|
this.failed = 0;
|
|
34
35
|
this.warnedMissing = new Set();
|
|
35
36
|
this.specBasename = path.basename(specFile);
|
|
36
|
-
|
|
37
|
-
const relative = resolveSpecRelativePath(specFile, inputPatterns).replace(
|
|
38
|
-
/\.ts$/i,
|
|
39
|
-
".snap",
|
|
40
|
-
);
|
|
41
|
-
this.filePath = path.join(dir, relative);
|
|
37
|
+
this.filePath = resolveSnapshotPath(specFile, snapshotDir, inputPatterns);
|
|
42
38
|
const sourcePath = existsSync(this.filePath) ? this.filePath : null;
|
|
43
39
|
const loaded = sourcePath
|
|
44
40
|
? readSnapshotFile(sourcePath, specFile)
|
|
@@ -543,7 +539,7 @@ function collectReadableLogs(suites) {
|
|
|
543
539
|
const suiteAny = suite;
|
|
544
540
|
const logs = Array.isArray(suiteAny.logs) ? suiteAny.logs : [];
|
|
545
541
|
for (const log of logs) {
|
|
546
|
-
const value = String(log.value ?? log.message ?? "");
|
|
542
|
+
const value = String(log.text ?? log.value ?? log.message ?? "");
|
|
547
543
|
if (value.length) out.push(value);
|
|
548
544
|
}
|
|
549
545
|
const childSuites = Array.isArray(suiteAny.suites) ? suiteAny.suites : [];
|
|
@@ -551,6 +547,133 @@ function collectReadableLogs(suites) {
|
|
|
551
547
|
}
|
|
552
548
|
return out;
|
|
553
549
|
}
|
|
550
|
+
// Walk a suite tree, accumulating each suite's `log()` output keyed by the
|
|
551
|
+
// describe/test description path it was emitted under.
|
|
552
|
+
function walkSuiteLogs(suites, pathParts, out) {
|
|
553
|
+
for (const suite of suites) {
|
|
554
|
+
const suiteAny = suite;
|
|
555
|
+
const description = String(suiteAny.description ?? "");
|
|
556
|
+
const nextPath = description.length
|
|
557
|
+
? [...pathParts, description]
|
|
558
|
+
: pathParts;
|
|
559
|
+
const logs = Array.isArray(suiteAny.logs) ? suiteAny.logs : [];
|
|
560
|
+
const lines = logs.map((log) =>
|
|
561
|
+
String(log.text ?? log.value ?? log.message ?? ""),
|
|
562
|
+
);
|
|
563
|
+
if (lines.length) out.push({ path: nextPath, lines });
|
|
564
|
+
const childSuites = Array.isArray(suiteAny.suites) ? suiteAny.suites : [];
|
|
565
|
+
walkSuiteLogs(childSuites, nextPath, out);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// Group every captured log across all file reports into a per-spec tree (one
|
|
569
|
+
// entry per `log()` call). Feeds the process-wide collector that backs the
|
|
570
|
+
// aggregated `latest.log` and the `--show-logs` dump.
|
|
571
|
+
function collectGroupedLogs(reports) {
|
|
572
|
+
let count = 0;
|
|
573
|
+
const groups = [];
|
|
574
|
+
for (const report of reports) {
|
|
575
|
+
const reportAny = report;
|
|
576
|
+
const suites = Array.isArray(reportAny.suites) ? reportAny.suites : [];
|
|
577
|
+
const entries = [];
|
|
578
|
+
walkSuiteLogs(suites, [], entries);
|
|
579
|
+
if (!entries.length) continue;
|
|
580
|
+
for (const entry of entries) count += entry.lines.length;
|
|
581
|
+
groups.push({ file: String(reportAny.file ?? "unknown"), entries });
|
|
582
|
+
}
|
|
583
|
+
return { count, groups };
|
|
584
|
+
}
|
|
585
|
+
// Process-lived collector backing the aggregated `latest.log`. Keyed by spec
|
|
586
|
+
// file, then by mode label, holding that mode's flat list of `log()` lines.
|
|
587
|
+
// Persisting across run() calls lets a multi-mode run accumulate every mode
|
|
588
|
+
// before the file is rendered, so identical output can be de-duplicated.
|
|
589
|
+
const collectedLogsBySpec = new Map();
|
|
590
|
+
// Clear the collector. Useful for watch mode, where each cycle should start
|
|
591
|
+
// fresh rather than accumulate stale specs.
|
|
592
|
+
export function resetCollectedLogs() {
|
|
593
|
+
collectedLogsBySpec.clear();
|
|
594
|
+
}
|
|
595
|
+
function recordModeLogs(modeLabel, groups) {
|
|
596
|
+
for (const group of groups) {
|
|
597
|
+
const lines = [];
|
|
598
|
+
for (const entry of group.entries) lines.push(...entry.lines);
|
|
599
|
+
if (!lines.length) continue;
|
|
600
|
+
let byMode = collectedLogsBySpec.get(group.file);
|
|
601
|
+
if (!byMode) {
|
|
602
|
+
byMode = new Map();
|
|
603
|
+
collectedLogsBySpec.set(group.file, byMode);
|
|
604
|
+
}
|
|
605
|
+
byMode.set(modeLabel, lines);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// Render the collected logs as the `latest.log` body. Within a spec, modes that
|
|
609
|
+
// produced byte-identical output are merged into one block tagged with every
|
|
610
|
+
// mode that emitted it:
|
|
611
|
+
//
|
|
612
|
+
// [LOG] log.spec.ts (node:bindings, node:wasi):
|
|
613
|
+
//
|
|
614
|
+
// {"a":1}
|
|
615
|
+
// ...
|
|
616
|
+
//
|
|
617
|
+
// `count` is the number of de-duplicated `log()` calls (one entry per call —
|
|
618
|
+
// stringify escapes newlines, so a call is a single line), not counting the
|
|
619
|
+
// same call again per mode.
|
|
620
|
+
function renderCollectedLogs() {
|
|
621
|
+
const blocks = [];
|
|
622
|
+
let count = 0;
|
|
623
|
+
const specs = [...collectedLogsBySpec.keys()].sort((a, b) =>
|
|
624
|
+
a.localeCompare(b),
|
|
625
|
+
);
|
|
626
|
+
for (const spec of specs) {
|
|
627
|
+
const byMode = collectedLogsBySpec.get(spec);
|
|
628
|
+
// Group modes by identical content so duplicate output collapses into one
|
|
629
|
+
// block; `calls` is that block's log() count, tallied once regardless of
|
|
630
|
+
// how many modes produced it.
|
|
631
|
+
const byContent = new Map();
|
|
632
|
+
for (const [mode, lines] of byMode) {
|
|
633
|
+
const content = lines.join("\n");
|
|
634
|
+
const existing = byContent.get(content);
|
|
635
|
+
if (existing) existing.modes.push(mode);
|
|
636
|
+
else byContent.set(content, { modes: [mode], calls: lines.length });
|
|
637
|
+
}
|
|
638
|
+
for (const [content, { modes, calls }] of byContent) {
|
|
639
|
+
const named = modes.filter((mode) => mode !== "default").sort();
|
|
640
|
+
const suffix = named.length ? ` (${named.join(", ")})` : "";
|
|
641
|
+
count += calls;
|
|
642
|
+
blocks.push(
|
|
643
|
+
`[LOG] ${formatSpecDisplayPath(spec)}${suffix}:\n\n${content}`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return { text: blocks.length ? blocks.join("\n\n") + "\n" : "", count };
|
|
648
|
+
}
|
|
649
|
+
// Render the collector and (re)write the single aggregated `latest.log` at the
|
|
650
|
+
// base (un-mode-qualified) logs dir, so every mode shares one file. Returns the
|
|
651
|
+
// resulting LogSummary. Called by run() after recording its own logs, so the
|
|
652
|
+
// last run() of a multi-mode pass leaves a file covering — and de-duplicating —
|
|
653
|
+
// every mode.
|
|
654
|
+
function flushLatestLog(baseLogsDir) {
|
|
655
|
+
const rendered = renderCollectedLogs();
|
|
656
|
+
if (rendered.count <= 0)
|
|
657
|
+
return { count: 0, file: null, groups: [], text: "" };
|
|
658
|
+
if (!baseLogsDir || baseLogsDir === "none") {
|
|
659
|
+
return {
|
|
660
|
+
count: rendered.count,
|
|
661
|
+
file: null,
|
|
662
|
+
groups: [],
|
|
663
|
+
text: rendered.text,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const logRoot = path.join(process.cwd(), baseLogsDir);
|
|
667
|
+
if (!existsSync(logRoot)) mkdirSync(logRoot, { recursive: true });
|
|
668
|
+
const latestLogPath = path.join(logRoot, "latest.log");
|
|
669
|
+
writeFileSync(latestLogPath, rendered.text);
|
|
670
|
+
return {
|
|
671
|
+
count: rendered.count,
|
|
672
|
+
file: path.relative(process.cwd(), latestLogPath) || latestLogPath,
|
|
673
|
+
groups: [],
|
|
674
|
+
text: rendered.text,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
554
677
|
export async function run(
|
|
555
678
|
flags = {},
|
|
556
679
|
configPath = DEFAULT_CONFIG_PATH,
|
|
@@ -766,6 +889,7 @@ export async function run(
|
|
|
766
889
|
} finally {
|
|
767
890
|
await ownedWebSession?.close();
|
|
768
891
|
}
|
|
892
|
+
const groupedLogs = collectGroupedLogs(reports);
|
|
769
893
|
if (config.logs && config.logs != "none") {
|
|
770
894
|
const logRoot = path.join(process.cwd(), config.logs);
|
|
771
895
|
if (!existsSync(logRoot)) {
|
|
@@ -786,6 +910,14 @@ export async function run(
|
|
|
786
910
|
);
|
|
787
911
|
}
|
|
788
912
|
}
|
|
913
|
+
// Record this run's logs (tagged with its mode) into the process-wide
|
|
914
|
+
// collector, then rewrite the single aggregated `latest.log` covering every
|
|
915
|
+
// mode seen so far. The collector persists across run() calls, so the last
|
|
916
|
+
// run() of a multi-mode pass produces the complete, de-duplicated file. The
|
|
917
|
+
// file lives at the base (un-mode-qualified) logs dir — `loadedConfig.logs`
|
|
918
|
+
// before `applyMode` appended the per-mode subdirectory.
|
|
919
|
+
recordModeLogs(options.modeName ?? "default", groupedLogs.groups);
|
|
920
|
+
const logSummary = flushLatestLog(loadedConfig.logs);
|
|
789
921
|
const stats = collectRunStats(reports);
|
|
790
922
|
if (options.fileSummaryTotal != undefined) {
|
|
791
923
|
applyConfiguredFileTotalToStats(stats, options.fileSummaryTotal);
|
|
@@ -824,6 +956,8 @@ export async function run(
|
|
|
824
956
|
showCoverage,
|
|
825
957
|
showCoverageAll: Boolean(flags.showCoverageAll),
|
|
826
958
|
verbose: Boolean(flags.verbose),
|
|
959
|
+
showLogs: Boolean(flags.showLogs),
|
|
960
|
+
logSummary,
|
|
827
961
|
buildTime,
|
|
828
962
|
snapshotSummary,
|
|
829
963
|
coverageSummary,
|
|
@@ -849,6 +983,7 @@ export async function run(
|
|
|
849
983
|
snapshotSummary,
|
|
850
984
|
coverageSummary,
|
|
851
985
|
reports,
|
|
986
|
+
logSummary,
|
|
852
987
|
};
|
|
853
988
|
}
|
|
854
989
|
function applyConfiguredFileTotalToStats(stats, fileSummaryTotal) {
|
|
@@ -2593,10 +2728,27 @@ function readFileReport(stats, fileReport) {
|
|
|
2593
2728
|
const buildCommand = String(fileReportAny.buildCommand ?? "");
|
|
2594
2729
|
let fileVerdict = "none";
|
|
2595
2730
|
for (const suite of suites) {
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2731
|
+
const suiteVerdict = readSuite(
|
|
2732
|
+
stats,
|
|
2733
|
+
suite,
|
|
2734
|
+
file,
|
|
2735
|
+
modeName,
|
|
2736
|
+
runCommand,
|
|
2737
|
+
buildCommand,
|
|
2599
2738
|
);
|
|
2739
|
+
fileVerdict = mergeVerdict(fileVerdict, suiteVerdict);
|
|
2740
|
+
// Record each failed top-level suite once. The failure summary recurses into
|
|
2741
|
+
// it to find every failed assertion (so nested failures aren't pushed again,
|
|
2742
|
+
// and a top-level it()/test() failure is captured too).
|
|
2743
|
+
if (suiteVerdict == "fail") {
|
|
2744
|
+
stats.failedEntries.push({
|
|
2745
|
+
...suite,
|
|
2746
|
+
file,
|
|
2747
|
+
modeName,
|
|
2748
|
+
runCommand,
|
|
2749
|
+
buildCommand,
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2600
2752
|
}
|
|
2601
2753
|
if (fileVerdict == "fail") {
|
|
2602
2754
|
stats.failedFiles++;
|
|
@@ -2608,7 +2760,6 @@ function readFileReport(stats, fileReport) {
|
|
|
2608
2760
|
}
|
|
2609
2761
|
function readSuite(stats, suite, file, modeName, runCommand, buildCommand) {
|
|
2610
2762
|
const suiteAny = suite;
|
|
2611
|
-
const kind = String(suiteAny.kind ?? "");
|
|
2612
2763
|
let verdict = normalizeVerdict(suiteAny.verdict);
|
|
2613
2764
|
const time = suiteAny.time;
|
|
2614
2765
|
const start = Number(time?.start ?? 0);
|
|
@@ -2633,27 +2784,11 @@ function readSuite(stats, suite, file, modeName, runCommand, buildCommand) {
|
|
|
2633
2784
|
stats.skippedTests++;
|
|
2634
2785
|
}
|
|
2635
2786
|
}
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
stats.failedTests++;
|
|
2640
|
-
} else if (verdict == "ok") {
|
|
2641
|
-
stats.passedTests++;
|
|
2642
|
-
} else if (verdict == "skip") {
|
|
2643
|
-
stats.skippedTests++;
|
|
2644
|
-
}
|
|
2645
|
-
}
|
|
2646
|
-
return verdict;
|
|
2647
|
-
}
|
|
2787
|
+
// Every grouping block — describe, test, it, only and their skip variants —
|
|
2788
|
+
// is a suite; the expect() assertions counted above are the tests. (Failed
|
|
2789
|
+
// entries for the summary are collected per top-level suite in readFileReport.)
|
|
2648
2790
|
if (verdict == "fail") {
|
|
2649
2791
|
stats.failedSuites++;
|
|
2650
|
-
stats.failedEntries.push({
|
|
2651
|
-
...suiteAny,
|
|
2652
|
-
file,
|
|
2653
|
-
modeName,
|
|
2654
|
-
runCommand,
|
|
2655
|
-
buildCommand,
|
|
2656
|
-
});
|
|
2657
2792
|
} else if (verdict == "ok") {
|
|
2658
2793
|
stats.passedSuites++;
|
|
2659
2794
|
} else {
|
|
@@ -2661,17 +2796,6 @@ function readSuite(stats, suite, file, modeName, runCommand, buildCommand) {
|
|
|
2661
2796
|
}
|
|
2662
2797
|
return verdict;
|
|
2663
2798
|
}
|
|
2664
|
-
function isTestCaseSuiteKind(kind) {
|
|
2665
|
-
return (
|
|
2666
|
-
kind == "test" ||
|
|
2667
|
-
kind == "it" ||
|
|
2668
|
-
kind == "only" ||
|
|
2669
|
-
kind == "xtest" ||
|
|
2670
|
-
kind == "xit" ||
|
|
2671
|
-
kind == "xonly" ||
|
|
2672
|
-
kind == "todo"
|
|
2673
|
-
);
|
|
2674
|
-
}
|
|
2675
2799
|
function normalizeVerdict(value) {
|
|
2676
2800
|
const verdict = String(value ?? "none");
|
|
2677
2801
|
if (verdict == "fail") return "fail";
|
package/bin/commands/run.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createRunReporter, run } from "./run-core.js";
|
|
1
|
+
export { createRunReporter, resetCollectedLogs, run } from "./run-core.js";
|
|
2
2
|
export async function executeRunCommand(
|
|
3
3
|
rawArgs,
|
|
4
4
|
flags,
|
|
@@ -19,6 +19,7 @@ export async function executeRunCommand(
|
|
|
19
19
|
showCoverage: showCoverageMode != undefined,
|
|
20
20
|
showCoverageAll: showCoverageMode == "all",
|
|
21
21
|
verbose: flags.includes("--verbose"),
|
|
22
|
+
showLogs: flags.includes("--show-logs"),
|
|
22
23
|
...deps.resolveParallelJobs(rawArgs, "run"),
|
|
23
24
|
coverage: featureToggles.coverage,
|
|
24
25
|
browser: deps.resolveBrowserOverride(rawArgs, "run"),
|
package/bin/commands/test.js
CHANGED
|
@@ -23,11 +23,12 @@ export async function executeTestCommand(
|
|
|
23
23
|
showCoverage: showCoverageMode != undefined,
|
|
24
24
|
showCoverageAll: showCoverageMode == "all",
|
|
25
25
|
verbose: flags.includes("--verbose"),
|
|
26
|
+
showLogs: flags.includes("--show-logs"),
|
|
26
27
|
...deps.resolveParallelJobs(rawArgs, "test"),
|
|
27
28
|
coverage: featureToggles.coverage,
|
|
28
29
|
browser: deps.resolveBrowserOverride(rawArgs, "test"),
|
|
29
30
|
reporterPath: deps.resolveReporterOverride(rawArgs, "test"),
|
|
30
|
-
watch: flags.includes("--watch"),
|
|
31
|
+
watch: flags.includes("--watch") || flags.includes("-w"),
|
|
31
32
|
};
|
|
32
33
|
const fuzzEnabled = flags.includes("--fuzz");
|
|
33
34
|
const fuzzOverrides = deps.resolveFuzzOverrides(rawArgs, "test");
|
|
Binary file
|