as-test 1.5.2 → 1.6.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 +23 -0
- package/as-test.config.schema.json +40 -0
- package/bin/build-cache.js +278 -0
- package/bin/commands/build-core.js +80 -15
- package/bin/commands/clean-core.js +4 -0
- package/bin/commands/run-core.js +182 -2
- package/bin/commands/test.js +2 -0
- package/bin/index.js +253 -68
- package/bin/reporters/default.js +56 -5
- package/bin/types.js +18 -0
- package/bin/util.js +94 -0
- package/package.json +2 -2
- package/transform/lib/index.js +2 -2
package/bin/commands/run-core.js
CHANGED
|
@@ -20,6 +20,11 @@ import { PassThrough } from "stream";
|
|
|
20
20
|
import { buildWebRunnerSource } from "./web-runner-source.js";
|
|
21
21
|
import { PersistentWebSessionHost } from "./web-session.js";
|
|
22
22
|
import { build } from "./build-core.js";
|
|
23
|
+
import {
|
|
24
|
+
cacheStorage,
|
|
25
|
+
reportHasFailure,
|
|
26
|
+
sha256OfFile,
|
|
27
|
+
} from "../build-cache.js";
|
|
23
28
|
import { resolveSpecFiles, emitSelectorWarnings } from "../selectors.js";
|
|
24
29
|
import { createReporter as createDefaultReporter } from "../reporters/default.js";
|
|
25
30
|
import { createTapReporter } from "../reporters/tap.js";
|
|
@@ -808,6 +813,7 @@ export async function run(
|
|
|
808
813
|
? await PersistentWebSessionHost.start(false)
|
|
809
814
|
: null;
|
|
810
815
|
const webSession = options.webSession ?? ownedWebSession;
|
|
816
|
+
const cacheCtx = cacheStorage.getStore();
|
|
811
817
|
try {
|
|
812
818
|
for (let i = 0; i < inputFiles.length; i++) {
|
|
813
819
|
const file = inputFiles[i];
|
|
@@ -815,6 +821,72 @@ export async function run(
|
|
|
815
821
|
config.outDir,
|
|
816
822
|
resolveArtifactPath(file, config.input),
|
|
817
823
|
);
|
|
824
|
+
// Tier 2: replay a stored passing report instead of running. build() ran
|
|
825
|
+
// first this session and validated the build is fresh (else it cleared
|
|
826
|
+
// the stored report), so here we only re-check the run-specific inputs.
|
|
827
|
+
if (cacheCtx?.replay) {
|
|
828
|
+
const snapPath = resolveSnapshotPath(
|
|
829
|
+
file,
|
|
830
|
+
config.snapshotDir,
|
|
831
|
+
config.input,
|
|
832
|
+
);
|
|
833
|
+
const snapshotSha = existsSync(snapPath)
|
|
834
|
+
? sha256OfFile(snapPath)
|
|
835
|
+
: null;
|
|
836
|
+
if (
|
|
837
|
+
cacheCtx.cache.canReplay(options.modeName, file, {
|
|
838
|
+
runtimeCmd: runtimeCommand,
|
|
839
|
+
snapshotSha,
|
|
840
|
+
})
|
|
841
|
+
) {
|
|
842
|
+
const cached = cacheCtx.cache.getReport(options.modeName, file);
|
|
843
|
+
if (cached && !reportHasFailure(cached)) {
|
|
844
|
+
const cachedSuites = Array.isArray(cached.suites)
|
|
845
|
+
? cached.suites
|
|
846
|
+
: [];
|
|
847
|
+
const selected = options.suiteSelectors?.length
|
|
848
|
+
? filterSelectedSuites(
|
|
849
|
+
cachedSuites,
|
|
850
|
+
options.suiteSelectors,
|
|
851
|
+
file,
|
|
852
|
+
options.modeName ?? "default",
|
|
853
|
+
)
|
|
854
|
+
: cachedSuites;
|
|
855
|
+
replayCachedReport(reporter, file, selected);
|
|
856
|
+
const cachedSnap = cached.snapshotSummary ?? {
|
|
857
|
+
matched: 0,
|
|
858
|
+
created: 0,
|
|
859
|
+
updated: 0,
|
|
860
|
+
failed: 0,
|
|
861
|
+
};
|
|
862
|
+
snapshotSummary.matched += cachedSnap.matched ?? 0;
|
|
863
|
+
snapshotSummary.created += cachedSnap.created ?? 0;
|
|
864
|
+
snapshotSummary.updated += cachedSnap.updated ?? 0;
|
|
865
|
+
snapshotSummary.failed += cachedSnap.failed ?? 0;
|
|
866
|
+
reports.push({
|
|
867
|
+
file,
|
|
868
|
+
modeName: options.modeName ?? "default",
|
|
869
|
+
suites: selected,
|
|
870
|
+
coverage: cached.coverage ?? {
|
|
871
|
+
total: 0,
|
|
872
|
+
covered: 0,
|
|
873
|
+
uncovered: 0,
|
|
874
|
+
percent: 100,
|
|
875
|
+
points: [],
|
|
876
|
+
},
|
|
877
|
+
runCommand: cached.runCommand ?? "",
|
|
878
|
+
buildCommand:
|
|
879
|
+
options.buildCommandsByFile?.[file] ??
|
|
880
|
+
options.buildCommand ??
|
|
881
|
+
cached.buildCommand ??
|
|
882
|
+
"",
|
|
883
|
+
snapshotSummary: cachedSnap,
|
|
884
|
+
cached: true,
|
|
885
|
+
});
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
818
890
|
if (!existsSync(outFile)) {
|
|
819
891
|
const buildStartedAt = Date.now();
|
|
820
892
|
await build(
|
|
@@ -929,7 +1001,7 @@ export async function run(
|
|
|
929
1001
|
snapshotSummary.created += snapshotStore.created;
|
|
930
1002
|
snapshotSummary.updated += snapshotStore.updated;
|
|
931
1003
|
snapshotSummary.failed += snapshotStore.failed;
|
|
932
|
-
|
|
1004
|
+
const fileReport = {
|
|
933
1005
|
file,
|
|
934
1006
|
modeName: options.modeName ?? "default",
|
|
935
1007
|
suites: selectedSuites,
|
|
@@ -943,7 +1015,29 @@ export async function run(
|
|
|
943
1015
|
updated: snapshotStore.updated,
|
|
944
1016
|
failed: snapshotStore.failed,
|
|
945
1017
|
},
|
|
946
|
-
|
|
1018
|
+
// When the cache is active, mark freshly-run reports `false` (replays
|
|
1019
|
+
// are marked `true`) so the summary can show a cache hit/miss split.
|
|
1020
|
+
// Left undefined when the cache is off so no Cache line is shown.
|
|
1021
|
+
...(cacheCtx?.cache ? { cached: false } : {}),
|
|
1022
|
+
};
|
|
1023
|
+
reports.push(fileReport);
|
|
1024
|
+
// Persist this report so an unchanged future run can replay it (Tier 2).
|
|
1025
|
+
// recordBuild ran during build() this session, so the entry exists.
|
|
1026
|
+
if (cacheCtx?.cache) {
|
|
1027
|
+
const snapPath = resolveSnapshotPath(
|
|
1028
|
+
file,
|
|
1029
|
+
config.snapshotDir,
|
|
1030
|
+
config.input,
|
|
1031
|
+
);
|
|
1032
|
+
const snapshotSha = existsSync(snapPath)
|
|
1033
|
+
? sha256OfFile(snapPath)
|
|
1034
|
+
: null;
|
|
1035
|
+
cacheCtx.cache.recordReport(options.modeName, file, {
|
|
1036
|
+
report: fileReport,
|
|
1037
|
+
snapshotSha,
|
|
1038
|
+
runtimeCmd: runtimeCommand,
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
947
1041
|
}
|
|
948
1042
|
} finally {
|
|
949
1043
|
await ownedWebSession?.close();
|
|
@@ -2160,6 +2254,22 @@ async function runProcess(
|
|
|
2160
2254
|
});
|
|
2161
2255
|
return synthesized;
|
|
2162
2256
|
}
|
|
2257
|
+
// A spec file with no test suites never calls `run()`, so it emits no
|
|
2258
|
+
// lifecycle frames and exits cleanly. That is an empty test file, not a
|
|
2259
|
+
// crash — mark it skipped instead of surfacing "missing report payload".
|
|
2260
|
+
if (
|
|
2261
|
+
code === 0 &&
|
|
2262
|
+
reportStream.dataFrames === 0 &&
|
|
2263
|
+
!runtimeEvents.sawFileStart &&
|
|
2264
|
+
!runtimeEvents.sawFileEnd &&
|
|
2265
|
+
runtimeEvents.suiteStarts === 0 &&
|
|
2266
|
+
!hasMeaningfulRuntimeOutput(stderrBuffer)
|
|
2267
|
+
) {
|
|
2268
|
+
reporter.onWarning?.({
|
|
2269
|
+
message: `${formatSpecDisplayPath(specFile)} contains no tests; marked as skipped`,
|
|
2270
|
+
});
|
|
2271
|
+
return createEmptyFileSkipReport(specFile, modeName);
|
|
2272
|
+
}
|
|
2163
2273
|
const errorText = "missing report payload from test runtime";
|
|
2164
2274
|
const diagnostics = buildRuntimeReportDiagnostics(
|
|
2165
2275
|
code,
|
|
@@ -2650,6 +2760,24 @@ function buildRuntimeReportDiagnostics(
|
|
|
2650
2760
|
`runtime events: fileStart=${runtimeEvents.sawFileStart ? "yes" : "no"}, fileEnd=${runtimeEvents.sawFileEnd ? "yes" : "no"}, fileVerdict=${runtimeEvents.fileVerdict}, suiteStarts=${runtimeEvents.suiteStarts}, suiteEnds=${runtimeEvents.suiteEnds}, assertionFails=${runtimeEvents.assertionFails}, warnings=${runtimeEvents.warnings}, logs=${runtimeEvents.logs}`,
|
|
2651
2761
|
].join("\n");
|
|
2652
2762
|
}
|
|
2763
|
+
function createEmptyFileSkipReport(specFile, modeName) {
|
|
2764
|
+
// No suites: a file with zero suites contributes no skipped-suite count, just
|
|
2765
|
+
// a skipped file (an empty `suites` array yields a "none" file verdict, which
|
|
2766
|
+
// collectRunStats tallies as a skipped file). The accompanying onWarning tells
|
|
2767
|
+
// the user the file had no tests.
|
|
2768
|
+
return {
|
|
2769
|
+
file: specFile,
|
|
2770
|
+
modeName: modeName ?? "default",
|
|
2771
|
+
suites: [],
|
|
2772
|
+
coverage: {
|
|
2773
|
+
total: 0,
|
|
2774
|
+
covered: 0,
|
|
2775
|
+
uncovered: 0,
|
|
2776
|
+
percent: 100,
|
|
2777
|
+
points: [],
|
|
2778
|
+
},
|
|
2779
|
+
};
|
|
2780
|
+
}
|
|
2653
2781
|
function createRuntimeFailureReport(
|
|
2654
2782
|
specFile,
|
|
2655
2783
|
modeName,
|
|
@@ -2745,6 +2873,58 @@ function shouldSuppressWasiWarningLine(line) {
|
|
|
2745
2873
|
}
|
|
2746
2874
|
return false;
|
|
2747
2875
|
}
|
|
2876
|
+
// Drive the reporter from a stored (passing) file report so a replayed spec
|
|
2877
|
+
// still scrolls past the live display and is counted, exactly as a fresh run
|
|
2878
|
+
// would render it. Only passing/skipped reports are replayed, so there are no
|
|
2879
|
+
// assertion failures to re-emit.
|
|
2880
|
+
function replayCachedReport(reporter, file, suites) {
|
|
2881
|
+
reporter.onFileStart?.({
|
|
2882
|
+
file,
|
|
2883
|
+
depth: 0,
|
|
2884
|
+
suiteKind: "file",
|
|
2885
|
+
description: file,
|
|
2886
|
+
});
|
|
2887
|
+
let verdict = "none";
|
|
2888
|
+
for (const suite of suites) {
|
|
2889
|
+
verdict = mergeReplayVerdict(
|
|
2890
|
+
verdict,
|
|
2891
|
+
emitReplaySuite(reporter, file, suite),
|
|
2892
|
+
);
|
|
2893
|
+
}
|
|
2894
|
+
reporter.onFileEnd?.({
|
|
2895
|
+
file,
|
|
2896
|
+
depth: 0,
|
|
2897
|
+
suiteKind: "file",
|
|
2898
|
+
description: file,
|
|
2899
|
+
verdict,
|
|
2900
|
+
cached: true,
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
function emitReplaySuite(reporter, file, suite) {
|
|
2904
|
+
const depth = Number(suite?.depth ?? 0);
|
|
2905
|
+
const kind = String(suite?.kind ?? "");
|
|
2906
|
+
const description = String(suite?.description ?? "");
|
|
2907
|
+
reporter.onSuiteStart?.({ file, depth, suiteKind: kind, description });
|
|
2908
|
+
let verdict = String(suite?.verdict ?? "none");
|
|
2909
|
+
const subs = Array.isArray(suite?.suites) ? suite.suites : [];
|
|
2910
|
+
for (const sub of subs) {
|
|
2911
|
+
verdict = mergeReplayVerdict(verdict, emitReplaySuite(reporter, file, sub));
|
|
2912
|
+
}
|
|
2913
|
+
reporter.onSuiteEnd?.({
|
|
2914
|
+
file,
|
|
2915
|
+
depth,
|
|
2916
|
+
suiteKind: kind,
|
|
2917
|
+
description,
|
|
2918
|
+
verdict: String(suite?.verdict ?? verdict),
|
|
2919
|
+
});
|
|
2920
|
+
return verdict;
|
|
2921
|
+
}
|
|
2922
|
+
function mergeReplayVerdict(a, b) {
|
|
2923
|
+
if (a === "fail" || b === "fail") return "fail";
|
|
2924
|
+
if (a === "ok" || b === "ok") return "ok";
|
|
2925
|
+
if (a === "skip" || b === "skip") return "skip";
|
|
2926
|
+
return "none";
|
|
2927
|
+
}
|
|
2748
2928
|
function collectRunStats(reports) {
|
|
2749
2929
|
const stats = {
|
|
2750
2930
|
passedFiles: 0,
|
package/bin/commands/test.js
CHANGED
|
@@ -29,6 +29,8 @@ export async function executeTestCommand(
|
|
|
29
29
|
browser: deps.resolveBrowserOverride(rawArgs, "test"),
|
|
30
30
|
reporterPath: deps.resolveReporterOverride(rawArgs, "test"),
|
|
31
31
|
watch: flags.includes("--watch") || flags.includes("-w"),
|
|
32
|
+
cache: flags.includes("--cache"),
|
|
33
|
+
noCache: flags.includes("--no-cache"),
|
|
32
34
|
};
|
|
33
35
|
const fuzzEnabled = flags.includes("--fuzz");
|
|
34
36
|
const fuzzOverrides = deps.resolveFuzzOverrides(rawArgs, "test");
|
package/bin/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
flushModeWarnings,
|
|
7
7
|
formatInvocation as formatBuildInvocation,
|
|
8
8
|
getBuildInvocationPreview,
|
|
9
|
+
getBuildReuseInfo,
|
|
9
10
|
warnOnUnknownModeReferences,
|
|
10
11
|
} from "./commands/build.js";
|
|
11
12
|
import { createRunReporter, resetCollectedLogs, run } from "./commands/run.js";
|
|
@@ -41,6 +42,8 @@ import { BuildWorkerPool } from "./build-worker-pool.js";
|
|
|
41
42
|
import { PersistentWebSessionHost } from "./commands/web-session.js";
|
|
42
43
|
import { buildRecorderStorage } from "./commands/build-core.js";
|
|
43
44
|
import { DependencyGraph } from "./dependency-graph.js";
|
|
45
|
+
import { BuildCache, cacheStorage, resolveCacheDir } from "./build-cache.js";
|
|
46
|
+
import { resolveCacheSettings } from "./util.js";
|
|
44
47
|
import { resolveSpecFiles, emitSelectorWarnings } from "./selectors.js";
|
|
45
48
|
const _args = process.argv.slice(2);
|
|
46
49
|
const flags = [];
|
|
@@ -477,6 +480,12 @@ function printCommandHelp(command) {
|
|
|
477
480
|
process.stdout.write(
|
|
478
481
|
" --watch, -w Re-run on source or spec changes\n",
|
|
479
482
|
);
|
|
483
|
+
process.stdout.write(
|
|
484
|
+
" --cache Skip recompiling/rerunning specs unchanged since the last run\n",
|
|
485
|
+
);
|
|
486
|
+
process.stdout.write(
|
|
487
|
+
" --no-cache Ignore the cache for this run (overrides config)\n",
|
|
488
|
+
);
|
|
480
489
|
process.stdout.write(" --help, -h Show this help\n");
|
|
481
490
|
return;
|
|
482
491
|
}
|
|
@@ -1239,31 +1248,57 @@ class ParallelQueueDisplay {
|
|
|
1239
1248
|
this.showStartLines = showStartLines;
|
|
1240
1249
|
this.active = new Map();
|
|
1241
1250
|
this.renderedLines = 0;
|
|
1251
|
+
// Files complete out of order under --parallel (a cache replay finishes
|
|
1252
|
+
// instantly while a fresh build is still running). To keep output in the
|
|
1253
|
+
// order specs were resolved, each token gets a start sequence (start() is
|
|
1254
|
+
// called in resolved/index order) and completed outputs are emitted only as
|
|
1255
|
+
// the contiguous prefix of that sequence fills in.
|
|
1256
|
+
this.seqByToken = new Map();
|
|
1257
|
+
this.nextSeq = 0;
|
|
1258
|
+
this.nextFlushSeq = 0;
|
|
1259
|
+
this.pending = new Map();
|
|
1242
1260
|
this.enabled = showStartLines && canRewriteParallelQueue();
|
|
1243
1261
|
}
|
|
1244
1262
|
start(file) {
|
|
1245
1263
|
const token = Symbol(file);
|
|
1246
|
-
|
|
1247
|
-
const line = `${chalk.bgBlackBright.white(" .... ")} ${file}`;
|
|
1264
|
+
this.seqByToken.set(token, this.nextSeq++);
|
|
1248
1265
|
if (!this.enabled) return token;
|
|
1266
|
+
const line = `${chalk.bgBlackBright.white(" .... ")} ${file}`;
|
|
1249
1267
|
this.clear();
|
|
1250
1268
|
this.active.set(token, line);
|
|
1251
1269
|
this.render();
|
|
1252
1270
|
return token;
|
|
1253
1271
|
}
|
|
1254
1272
|
complete(token, output) {
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
return;
|
|
1258
|
-
}
|
|
1259
|
-
this.clear();
|
|
1260
|
-
process.stdout.write(output);
|
|
1273
|
+
const seq = this.seqByToken.get(token) ?? this.nextFlushSeq;
|
|
1274
|
+
this.seqByToken.delete(token);
|
|
1261
1275
|
this.active.delete(token);
|
|
1262
|
-
this.
|
|
1276
|
+
this.pending.set(seq, output);
|
|
1277
|
+
this.flushOrdered();
|
|
1278
|
+
}
|
|
1279
|
+
// Emit the contiguous run of completed outputs starting at nextFlushSeq, so
|
|
1280
|
+
// results print in resolved order regardless of completion order.
|
|
1281
|
+
flushOrdered() {
|
|
1282
|
+
if (!this.pending.has(this.nextFlushSeq)) return;
|
|
1283
|
+
if (this.enabled) this.clear();
|
|
1284
|
+
while (this.pending.has(this.nextFlushSeq)) {
|
|
1285
|
+
process.stdout.write(this.pending.get(this.nextFlushSeq));
|
|
1286
|
+
this.pending.delete(this.nextFlushSeq);
|
|
1287
|
+
this.nextFlushSeq++;
|
|
1288
|
+
}
|
|
1289
|
+
if (this.enabled) this.render();
|
|
1263
1290
|
}
|
|
1264
1291
|
flush() {
|
|
1265
|
-
|
|
1266
|
-
|
|
1292
|
+
// Drain anything still buffered (e.g. a gap left by an errored spec) in
|
|
1293
|
+
// sequence order, then clear the live block.
|
|
1294
|
+
if (this.pending.size) {
|
|
1295
|
+
if (this.enabled) this.clear();
|
|
1296
|
+
for (const seq of [...this.pending.keys()].sort((a, b) => a - b)) {
|
|
1297
|
+
process.stdout.write(this.pending.get(seq));
|
|
1298
|
+
}
|
|
1299
|
+
this.pending.clear();
|
|
1300
|
+
}
|
|
1301
|
+
if (this.enabled) this.clear();
|
|
1267
1302
|
}
|
|
1268
1303
|
clear() {
|
|
1269
1304
|
if (!this.renderedLines) return;
|
|
@@ -1329,6 +1364,29 @@ async function buildFileForMode(args) {
|
|
|
1329
1364
|
// recorder so the dependency graph still gets populated under --parallel.
|
|
1330
1365
|
const recorder = buildRecorderStorage.getStore();
|
|
1331
1366
|
if (args.buildPool) {
|
|
1367
|
+
// The non-pool branch delegates to build(), which owns the cache logic.
|
|
1368
|
+
// The pool builds in child processes, so handle the cache here: skip when
|
|
1369
|
+
// fresh, else collect the worker's reported reads and record them.
|
|
1370
|
+
const cacheCtx = cacheStorage.getStore();
|
|
1371
|
+
const reuse = cacheCtx
|
|
1372
|
+
? await getBuildReuseInfo(
|
|
1373
|
+
args.configPath,
|
|
1374
|
+
args.file,
|
|
1375
|
+
args.modeName,
|
|
1376
|
+
args.buildFeatureToggles,
|
|
1377
|
+
)
|
|
1378
|
+
: null;
|
|
1379
|
+
if (
|
|
1380
|
+
cacheCtx &&
|
|
1381
|
+
reuse &&
|
|
1382
|
+
cacheCtx.cache.isBuildFresh(args.modeName, args.file, {
|
|
1383
|
+
signature: reuse.signature,
|
|
1384
|
+
coverageEnabled: reuse.coverageEnabled,
|
|
1385
|
+
})
|
|
1386
|
+
) {
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
const reads = cacheCtx && reuse ? new Set() : null;
|
|
1332
1390
|
const buildInvocation = await getBuildInvocationPreview(
|
|
1333
1391
|
args.configPath,
|
|
1334
1392
|
args.file,
|
|
@@ -1341,12 +1399,24 @@ async function buildFileForMode(args) {
|
|
|
1341
1399
|
modeName: args.modeName,
|
|
1342
1400
|
buildCommand: formatBuildInvocation(buildInvocation),
|
|
1343
1401
|
featureToggles: args.buildFeatureToggles,
|
|
1344
|
-
onReads:
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1402
|
+
onReads:
|
|
1403
|
+
recorder || reads
|
|
1404
|
+
? (entries) => {
|
|
1405
|
+
for (const r of entries) {
|
|
1406
|
+
recorder?.record(r.mode, r.spec, r.file);
|
|
1407
|
+
reads?.add(r.file);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
: undefined,
|
|
1349
1411
|
});
|
|
1412
|
+
if (cacheCtx && reuse && reads) {
|
|
1413
|
+
cacheCtx.cache.recordBuild(args.modeName, args.file, {
|
|
1414
|
+
signature: reuse.signature,
|
|
1415
|
+
outFile: reuse.outFile,
|
|
1416
|
+
deps: reads,
|
|
1417
|
+
coverageEnabled: reuse.coverageEnabled,
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1350
1420
|
} else {
|
|
1351
1421
|
await build(
|
|
1352
1422
|
args.configPath,
|
|
@@ -1949,17 +2019,70 @@ async function runTestModes(
|
|
|
1949
2019
|
);
|
|
1950
2020
|
return;
|
|
1951
2021
|
}
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
suiteSelectors,
|
|
1957
|
-
fuzzerSelectors,
|
|
1958
|
-
modes,
|
|
1959
|
-
buildFeatureToggles,
|
|
1960
|
-
fuzzEnabled,
|
|
1961
|
-
fuzzOverrides,
|
|
2022
|
+
// Opt-in incremental cache for the whole non-watch run (watch keeps its own
|
|
2023
|
+
// in-memory dependency graph). Fuzzing is non-deterministic, so never cache.
|
|
2024
|
+
const baseConfig = loadConfig(
|
|
2025
|
+
configPath ?? path.join(process.cwd(), "./as-test.config.json"),
|
|
1962
2026
|
);
|
|
2027
|
+
const { mode: cacheMode, maxTimeMs } = resolveCacheSettings(
|
|
2028
|
+
baseConfig.cache,
|
|
2029
|
+
{
|
|
2030
|
+
cache: runFlags.cache,
|
|
2031
|
+
noCache: runFlags.noCache,
|
|
2032
|
+
},
|
|
2033
|
+
);
|
|
2034
|
+
const cacheEnabled = cacheMode !== "off" && !fuzzEnabled;
|
|
2035
|
+
if (!cacheEnabled) {
|
|
2036
|
+
const failed = await runTestModesCore(
|
|
2037
|
+
runFlags,
|
|
2038
|
+
configPath,
|
|
2039
|
+
selectors,
|
|
2040
|
+
suiteSelectors,
|
|
2041
|
+
fuzzerSelectors,
|
|
2042
|
+
modes,
|
|
2043
|
+
buildFeatureToggles,
|
|
2044
|
+
fuzzEnabled,
|
|
2045
|
+
fuzzOverrides,
|
|
2046
|
+
);
|
|
2047
|
+
process.exit(failed ? 1 : 0);
|
|
2048
|
+
}
|
|
2049
|
+
const cacheDir = resolveCacheDir(baseConfig.outDir);
|
|
2050
|
+
const cache = BuildCache.load(cacheDir, getCliVersion(), { maxTimeMs });
|
|
2051
|
+
// Replay (Tier 2) is unsafe while writing snapshots — those mutate .snap, so
|
|
2052
|
+
// a replayed snapshot summary would be wrong. Build-skip still applies.
|
|
2053
|
+
const replay =
|
|
2054
|
+
cacheMode === "full" &&
|
|
2055
|
+
!runFlags.createSnapshots &&
|
|
2056
|
+
!runFlags.overwriteSnapshots;
|
|
2057
|
+
// Only prune entries on a full run: a selector-scoped run (`ast test foo`)
|
|
2058
|
+
// resolves a subset, so pruning to it would wipe every other spec's entry.
|
|
2059
|
+
let liveKeys = null;
|
|
2060
|
+
if (!selectors.length) {
|
|
2061
|
+
const files = await resolveSelectedFiles(configPath, [], false);
|
|
2062
|
+
liveKeys = new Set();
|
|
2063
|
+
for (const modeName of modes) {
|
|
2064
|
+
for (const file of files) liveKeys.add(cache.keyFor(modeName, file));
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
let failed = false;
|
|
2068
|
+
try {
|
|
2069
|
+
failed = await cacheStorage.run({ cache, replay }, () =>
|
|
2070
|
+
runTestModesCore(
|
|
2071
|
+
runFlags,
|
|
2072
|
+
configPath,
|
|
2073
|
+
selectors,
|
|
2074
|
+
suiteSelectors,
|
|
2075
|
+
fuzzerSelectors,
|
|
2076
|
+
modes,
|
|
2077
|
+
buildFeatureToggles,
|
|
2078
|
+
fuzzEnabled,
|
|
2079
|
+
fuzzOverrides,
|
|
2080
|
+
),
|
|
2081
|
+
);
|
|
2082
|
+
} finally {
|
|
2083
|
+
if (liveKeys) cache.prune(liveKeys);
|
|
2084
|
+
cache.save();
|
|
2085
|
+
}
|
|
1963
2086
|
process.exit(failed ? 1 : 0);
|
|
1964
2087
|
}
|
|
1965
2088
|
async function runWatchLoop(
|
|
@@ -1977,6 +2100,43 @@ async function runWatchLoop(
|
|
|
1977
2100
|
configPath ?? path.join(process.cwd(), "./as-test.config.json");
|
|
1978
2101
|
const absConfigPath = path.resolve(resolvedConfigPath);
|
|
1979
2102
|
let config = loadConfig(resolvedConfigPath, false);
|
|
2103
|
+
// Persistent incremental cache, honored under --watch too (the dependency
|
|
2104
|
+
// graph above only governs which specs re-run; the cache skips recompiling /
|
|
2105
|
+
// replays unchanged ones, which makes the initial watch run and "run all"
|
|
2106
|
+
// fast). Resolved from flags+config; toggleable live with `c`.
|
|
2107
|
+
const initialCache = resolveCacheSettings(config.cache, {
|
|
2108
|
+
cache: runFlags.cache,
|
|
2109
|
+
noCache: runFlags.noCache,
|
|
2110
|
+
});
|
|
2111
|
+
let cacheMode = fuzzEnabled ? "off" : initialCache.mode;
|
|
2112
|
+
const cacheMaxTimeMs = initialCache.maxTimeMs;
|
|
2113
|
+
// The mode `c` toggles back on to (the configured mode, or "full" if the
|
|
2114
|
+
// cache started off).
|
|
2115
|
+
const cacheToggleMode =
|
|
2116
|
+
initialCache.mode === "off" ? "full" : initialCache.mode;
|
|
2117
|
+
// Wraps a run in the cache context (fresh per run so `now`/maxTime are
|
|
2118
|
+
// current), unless the cache is off. Entry-pruning is intentionally skipped
|
|
2119
|
+
// under watch — scoped re-runs resolve only a subset of specs, so pruning to
|
|
2120
|
+
// them would wipe the rest of the cache.
|
|
2121
|
+
async function withCache(fn) {
|
|
2122
|
+
if (cacheMode === "off") {
|
|
2123
|
+
await fn();
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
const cacheDir = resolveCacheDir(config.outDir);
|
|
2127
|
+
const cache = BuildCache.load(cacheDir, getCliVersion(), {
|
|
2128
|
+
maxTimeMs: cacheMaxTimeMs,
|
|
2129
|
+
});
|
|
2130
|
+
const replay =
|
|
2131
|
+
cacheMode === "full" &&
|
|
2132
|
+
!runFlags.createSnapshots &&
|
|
2133
|
+
!runFlags.overwriteSnapshots;
|
|
2134
|
+
try {
|
|
2135
|
+
await cacheStorage.run({ cache, replay }, fn);
|
|
2136
|
+
} finally {
|
|
2137
|
+
cache.save();
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
1980
2140
|
// Respect the user's parallelism flags. Worker-pool builds forward their
|
|
1981
2141
|
// file-read records back through IPC (see BuildWorkerPool / build-worker
|
|
1982
2142
|
// and buildFileForMode), so the dependency graph stays correct under
|
|
@@ -2026,12 +2186,14 @@ async function runWatchLoop(
|
|
|
2026
2186
|
: "";
|
|
2027
2187
|
return chalk.dim(
|
|
2028
2188
|
`Auto-run paused${pending}. ` +
|
|
2029
|
-
chalk.bold("w") +
|
|
2030
|
-
" = resume, " +
|
|
2031
|
-
chalk.bold("a") +
|
|
2032
|
-
" = re-run all, " +
|
|
2033
2189
|
chalk.bold("space") +
|
|
2034
2190
|
" = retry failing, " +
|
|
2191
|
+
chalk.bold("a") +
|
|
2192
|
+
" = re-run all, " +
|
|
2193
|
+
chalk.bold("w") +
|
|
2194
|
+
" = resume, " +
|
|
2195
|
+
chalk.bold("c") +
|
|
2196
|
+
` = cache (${cacheMode === "off" ? "off" : "on"}), ` +
|
|
2035
2197
|
chalk.bold("ctrl+c") +
|
|
2036
2198
|
" = stop.\n",
|
|
2037
2199
|
);
|
|
@@ -2044,10 +2206,23 @@ async function runWatchLoop(
|
|
|
2044
2206
|
" = re-run all, " +
|
|
2045
2207
|
chalk.bold("w") +
|
|
2046
2208
|
" = pause, " +
|
|
2209
|
+
chalk.bold("c") +
|
|
2210
|
+
` = cache (${cacheMode === "off" ? "off" : "on"}), ` +
|
|
2047
2211
|
chalk.bold("ctrl+c") +
|
|
2048
2212
|
" = stop.\n",
|
|
2049
2213
|
);
|
|
2050
2214
|
}
|
|
2215
|
+
// Rewrites the footer line in place (the cursor sits one line below it after
|
|
2216
|
+
// every doRun / prior rewrite), so toggling w/c updates the hint without
|
|
2217
|
+
// accumulating new lines in scrollback. Falls back to a plain write when not
|
|
2218
|
+
// on a rewritable TTY.
|
|
2219
|
+
function rewriteFooter() {
|
|
2220
|
+
if (process.stdout.isTTY) {
|
|
2221
|
+
process.stdout.write("\x1b[1A\r\x1b[2K" + watchFooter());
|
|
2222
|
+
} else {
|
|
2223
|
+
process.stdout.write(watchFooter());
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2051
2226
|
function writeWatchHeader(headline, detail) {
|
|
2052
2227
|
// Preserve scrollback — never `console.clear()`. A blank line plus a
|
|
2053
2228
|
// dim rule visually delimits each iteration so prior output stays
|
|
@@ -2185,21 +2360,23 @@ async function runWatchLoop(
|
|
|
2185
2360
|
},
|
|
2186
2361
|
};
|
|
2187
2362
|
try {
|
|
2188
|
-
await
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2363
|
+
await withCache(() =>
|
|
2364
|
+
buildRecorderStorage.run(recorder, async () => {
|
|
2365
|
+
await runTestModesCore(
|
|
2366
|
+
watchRunFlags,
|
|
2367
|
+
configPath,
|
|
2368
|
+
runSelectors,
|
|
2369
|
+
suiteSelectors,
|
|
2370
|
+
fuzzerSelectors,
|
|
2371
|
+
modes,
|
|
2372
|
+
buildFeatureToggles,
|
|
2373
|
+
fuzzEnabled,
|
|
2374
|
+
fuzzOverrides,
|
|
2375
|
+
(outcome) =>
|
|
2376
|
+
recordSpecOutcome(outcome.file, outcome.mode, outcome.failed),
|
|
2377
|
+
);
|
|
2378
|
+
}),
|
|
2379
|
+
);
|
|
2203
2380
|
} catch (error) {
|
|
2204
2381
|
const message = error instanceof Error ? error.message : String(error);
|
|
2205
2382
|
process.stderr.write(chalk.red("Error: ") + message + "\n");
|
|
@@ -2352,31 +2529,14 @@ async function runWatchLoop(
|
|
|
2352
2529
|
}
|
|
2353
2530
|
if (isRunning) break;
|
|
2354
2531
|
if (byte === 0x77 || byte === 0x57) {
|
|
2355
|
-
// `w` — toggle auto-run / manual mode.
|
|
2532
|
+
// `w` — toggle auto-run / manual mode. Resuming with edits pending
|
|
2533
|
+
// kicks off a run (which prints its own header); otherwise just
|
|
2534
|
+
// refresh the footer in place.
|
|
2356
2535
|
autoRun = !autoRun;
|
|
2357
|
-
if (autoRun) {
|
|
2358
|
-
|
|
2359
|
-
if (hadPending) {
|
|
2360
|
-
process.stdout.write(
|
|
2361
|
-
"\n" +
|
|
2362
|
-
chalk.dim(
|
|
2363
|
-
"Auto-run resumed — re-running all (files changed while paused).\n",
|
|
2364
|
-
),
|
|
2365
|
-
);
|
|
2366
|
-
scheduleManualRerun("manual-runall");
|
|
2367
|
-
} else {
|
|
2368
|
-
process.stdout.write(
|
|
2369
|
-
"\n" + chalk.dim("Auto-run resumed.\n") + watchFooter(),
|
|
2370
|
-
);
|
|
2371
|
-
}
|
|
2536
|
+
if (autoRun && changedWhilePaused.size > 0) {
|
|
2537
|
+
scheduleManualRerun("manual-runall");
|
|
2372
2538
|
} else {
|
|
2373
|
-
|
|
2374
|
-
"\n" +
|
|
2375
|
-
chalk.dim(
|
|
2376
|
-
"Auto-run paused — edits won't re-run. Press w to resume, or a / space to run now.\n",
|
|
2377
|
-
) +
|
|
2378
|
-
watchFooter(),
|
|
2379
|
-
);
|
|
2539
|
+
rewriteFooter();
|
|
2380
2540
|
}
|
|
2381
2541
|
break;
|
|
2382
2542
|
}
|
|
@@ -2388,6 +2548,15 @@ async function runWatchLoop(
|
|
|
2388
2548
|
scheduleManualRerun("manual-runall");
|
|
2389
2549
|
break;
|
|
2390
2550
|
}
|
|
2551
|
+
if (byte === 0x63 || byte === 0x43) {
|
|
2552
|
+
// `c` — toggle the incremental cache for subsequent runs. No-op
|
|
2553
|
+
// while fuzzing (the cache is unsafe there). Updates the footer in
|
|
2554
|
+
// place to reflect the new state.
|
|
2555
|
+
if (fuzzEnabled) break;
|
|
2556
|
+
cacheMode = cacheMode === "off" ? cacheToggleMode : "off";
|
|
2557
|
+
rewriteFooter();
|
|
2558
|
+
break;
|
|
2559
|
+
}
|
|
2391
2560
|
}
|
|
2392
2561
|
});
|
|
2393
2562
|
} catch {
|
|
@@ -3575,6 +3744,22 @@ function formatMatrixFileResultLine(
|
|
|
3575
3744
|
showPerModeTimes,
|
|
3576
3745
|
) {
|
|
3577
3746
|
const verdict = resolveMatrixVerdict(results);
|
|
3747
|
+
// A file whose every mode was replayed from cache is de-emphasized: dim-grey
|
|
3748
|
+
// badge + filename, and "(cache)" in place of the timing.
|
|
3749
|
+
const cached =
|
|
3750
|
+
results.length > 0 &&
|
|
3751
|
+
results.every(
|
|
3752
|
+
(r) => r.reports.length > 0 && r.reports.every((rep) => rep.cached),
|
|
3753
|
+
);
|
|
3754
|
+
if (cached) {
|
|
3755
|
+
const badge =
|
|
3756
|
+
verdict == "fail"
|
|
3757
|
+
? chalk.bgRed.white(" FAIL ")
|
|
3758
|
+
: verdict == "ok"
|
|
3759
|
+
? chalk.bgGreenBright.white(" PASS ")
|
|
3760
|
+
: chalk.bgBlackBright.white(" SKIP ");
|
|
3761
|
+
return `${badge} ${chalk.dim(file)} ${chalk.dim("(cache)")}`;
|
|
3762
|
+
}
|
|
3578
3763
|
const badge =
|
|
3579
3764
|
verdict == "fail"
|
|
3580
3765
|
? chalk.bgRed.white(" FAIL ")
|