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.
@@ -116,14 +116,28 @@ class DefaultReporter {
116
116
  renderFileResult(event) {
117
117
  const verdict = event.verdict ?? "none";
118
118
  const time = event.time ? ` ${chalk.dim(event.time)}` : "";
119
- const file = formatSpecDisplayPath(event.file);
119
+ const name = formatSpecDisplayPath(event.file);
120
+ // A replayed (cached) result is de-emphasized: badge, filename, and tag are
121
+ // all dimmed so freshly-run specs stand out from unchanged ones.
122
+ if (event.cached) {
123
+ // Replayed-from-cache: keep the coloured verdict badge (white text) so it
124
+ // stays scannable, but dim the filename and show "(cache)" in place of the
125
+ // timing so freshly-run specs still stand out.
126
+ const badge =
127
+ verdict == "fail"
128
+ ? chalk.bgRed.white(" FAIL ")
129
+ : verdict == "ok"
130
+ ? chalk.bgGreenBright.white(" PASS ")
131
+ : chalk.bgBlackBright.white(" SKIP ");
132
+ return `${badge} ${chalk.dim(name)} ${chalk.dim("(cache)")}`;
133
+ }
120
134
  if (verdict == "fail")
121
- return `${chalk.bgRed.white(" FAIL ")} ${file}${time}`;
135
+ return `${chalk.bgRed.white(" FAIL ")} ${name}${time}`;
122
136
  if (this.fileHasWarning)
123
- return `${chalk.bgYellow.black(" WARN ")} ${file}${time}`;
137
+ return `${chalk.bgYellow.black(" WARN ")} ${name}${time}`;
124
138
  if (verdict == "ok")
125
- return `${chalk.bgGreenBright.black(" PASS ")} ${file}${time}`;
126
- return `${chalk.bgBlackBright.white(" SKIP ")} ${file}${time}`;
139
+ return `${chalk.bgGreenBright.black(" PASS ")} ${name}${time}`;
140
+ return `${chalk.bgBlackBright.white(" SKIP ")} ${name}${time}`;
127
141
  }
128
142
  onRunStart(event) {
129
143
  this.verboseMode = Boolean(event.verbose);
@@ -805,8 +819,18 @@ function renderTotals(stats, event) {
805
819
  skipped: stats.skippedTests,
806
820
  total: stats.failedTests + stats.passedTests + stats.skippedTests,
807
821
  };
822
+ const cacheSummary = computeCacheSummary(event.reports);
808
823
  const layout = createSummaryLayout([
809
824
  event.fuzzSummary,
825
+ // "cached" and "failed" are the same length, so the cache summary aligns in
826
+ // the shared first column.
827
+ cacheSummary
828
+ ? {
829
+ failed: cacheSummary.cached,
830
+ skipped: cacheSummary.skipped,
831
+ total: cacheSummary.total,
832
+ }
833
+ : undefined,
810
834
  filesSummary,
811
835
  suitesSummary,
812
836
  testsSummary,
@@ -821,6 +845,9 @@ function renderTotals(stats, event) {
821
845
  if (event.modeSummary) {
822
846
  renderModeSummary(event.modeSummary, layout);
823
847
  }
848
+ if (cacheSummary) {
849
+ renderCacheSummary(cacheSummary, layout);
850
+ }
824
851
  process.stdout.write(
825
852
  chalk.bold("Time:".padEnd(9)) +
826
853
  formatTime(stats.time) +
@@ -828,6 +855,30 @@ function renderTotals(stats, event) {
828
855
  "\n",
829
856
  );
830
857
  }
858
+ // When the cache is active, every report carries a `cached` flag (true =
859
+ // replayed from cache, false = freshly run). Returns the hit/miss split, or
860
+ // undefined when the cache is off (no report sets the flag) so no line shows.
861
+ function computeCacheSummary(reports) {
862
+ const flagged = reports.filter((r) => typeof r?.cached === "boolean");
863
+ if (!flagged.length) return undefined;
864
+ const cached = flagged.filter((r) => r.cached).length;
865
+ return { cached, skipped: flagged.length - cached, total: flagged.length };
866
+ }
867
+ // Renders the "Cache:" line in the shared three-column layout (cached / skipped
868
+ // / total) so it lines up with Files/Suites/Tests/Modes.
869
+ function renderCacheSummary(summary, layout) {
870
+ const cachedText = `${summary.cached} cached`;
871
+ const skippedText = `${summary.skipped} skipped`;
872
+ const totalText = `${summary.total} total`;
873
+ process.stdout.write(chalk.bold("Cache:".padEnd(9)));
874
+ process.stdout.write(
875
+ chalk.bold.greenBright(cachedText.padStart(layout.failedWidth)),
876
+ );
877
+ process.stdout.write(", ");
878
+ process.stdout.write(chalk.gray(skippedText.padStart(layout.skippedWidth)));
879
+ process.stdout.write(", ");
880
+ process.stdout.write(totalText.padStart(layout.totalWidth) + "\n");
881
+ }
831
882
  function renderModeSummary(summary, layout) {
832
883
  renderSummaryLine("Modes:", summary, layout);
833
884
  }
package/bin/types.js CHANGED
@@ -7,6 +7,13 @@ export class Config {
7
7
  this.coverageDir = "./.as-test/coverage";
8
8
  this.snapshotDir = "./.as-test/snapshots";
9
9
  this.config = "none";
10
+ // Incremental test cache (opt-in). false = off; "build" = skip recompiling
11
+ // unchanged specs; "full"/true = also replay passing run results. The object
12
+ // form adds `maxTime` (e.g. "1h", "30m", "7d") to expire entries older than
13
+ // that, forcing a rebuild+rerun. ("reachable" is accepted as a deprecated
14
+ // alias for "full" — reachability-based dep pruning was unsound for
15
+ // AssemblyScript's compile-time inlining.) Enable per run with --cache / --no-cache.
16
+ this.cache = false;
10
17
  this.coverage = false;
11
18
  this.features = [];
12
19
  this.env = {};
@@ -16,6 +23,17 @@ export class Config {
16
23
  this.modes = {};
17
24
  }
18
25
  }
26
+ export class CacheOptions {
27
+ constructor() {
28
+ // Cache tier: "build" (skip rebuilds) or "full" (also replay runs).
29
+ // "reachable" is accepted as a deprecated alias for "full".
30
+ this.type = "full";
31
+ // Optional expiry: a duration string (e.g. "1h", "30m", "90s", "7d"). Entries
32
+ // built longer ago than this are treated as stale and rebuilt+rerun. Empty =
33
+ // no expiry.
34
+ this.maxTime = "";
35
+ }
36
+ }
19
37
  export const INTERNAL_FEATURE_NAMES = new Set(["try-as"]);
20
38
  export function normalizeFeatureName(value) {
21
39
  const trimmed = value.trim().toLowerCase();
package/bin/util.js CHANGED
@@ -217,6 +217,7 @@ const TOP_LEVEL_KEYS = new Set([
217
217
  "coverageDir",
218
218
  "snapshotDir",
219
219
  "config",
220
+ "cache",
220
221
  "coverage",
221
222
  "features",
222
223
  "env",
@@ -253,6 +254,7 @@ function validateConfig(raw, configPath) {
253
254
  validateStringField(raw, "coverageDir", "$", issues);
254
255
  validateStringField(raw, "snapshotDir", "$", issues);
255
256
  validateStringField(raw, "config", "$", issues);
257
+ validateCacheField(raw, "cache", "$", issues);
256
258
  validateCoverageField(raw, "coverage", "$", issues);
257
259
  validateFeaturesField(raw, "features", "$", issues);
258
260
  validateEnvField(raw, "env", "$", issues);
@@ -380,6 +382,46 @@ function validateCoverageField(raw, key, pathPrefix, issues) {
380
382
  if (!(key in raw) || raw[key] == undefined) return;
381
383
  validateCoverageValue(raw[key], `${pathPrefix}.${key}`, issues);
382
384
  }
385
+ function validateCacheField(raw, key, pathPrefix, issues) {
386
+ if (!(key in raw) || raw[key] == undefined) return;
387
+ const value = raw[key];
388
+ const path = `${pathPrefix}.${key}`;
389
+ if (typeof value == "boolean") return;
390
+ if (value === "build" || value === "full" || value === "reachable") return;
391
+ if (value && typeof value == "object" && !Array.isArray(value)) {
392
+ const obj = value;
393
+ if (
394
+ "type" in obj &&
395
+ obj.type !== "build" &&
396
+ obj.type !== "full" &&
397
+ obj.type !== "reachable"
398
+ ) {
399
+ issues.push({
400
+ path: `${path}.type`,
401
+ message: 'must be "build" or "full"',
402
+ fix: 'set "type" to "build" or "full"',
403
+ });
404
+ }
405
+ if ("maxTime" in obj && obj.maxTime != undefined) {
406
+ if (
407
+ typeof obj.maxTime != "string" ||
408
+ parseDurationMs(obj.maxTime) == null
409
+ ) {
410
+ issues.push({
411
+ path: `${path}.maxTime`,
412
+ message: 'must be a duration like "1h", "30m", "90s", or "7d"',
413
+ fix: 'use a number followed by ms/s/m/h/d, e.g. "1h"',
414
+ });
415
+ }
416
+ }
417
+ return;
418
+ }
419
+ issues.push({
420
+ path,
421
+ message: 'must be a boolean, "build"/"full", or { type, maxTime }',
422
+ fix: 'use false, true, "build", "full", or { "type": "full", "maxTime": "1h" }',
423
+ });
424
+ }
383
425
  function validateCoverageValue(value, path, issues) {
384
426
  if (typeof value == "boolean") return;
385
427
  if (!value || typeof value != "object" || Array.isArray(value)) {
@@ -758,6 +800,7 @@ function validateModesField(raw, key, pathPrefix, issues) {
758
800
  validateStringField(modeObj, "coverageDir", modePath, issues);
759
801
  validateStringField(modeObj, "snapshotDir", modePath, issues);
760
802
  validateStringField(modeObj, "config", modePath, issues);
803
+ validateCacheField(modeObj, "cache", modePath, issues);
761
804
  validateCoverageField(modeObj, "coverage", modePath, issues);
762
805
  validateFeaturesField(modeObj, "features", modePath, issues);
763
806
  validateFuzzField(modeObj, "fuzz", modePath, issues);
@@ -1244,6 +1287,7 @@ function mergeRootConfig(base, override) {
1244
1287
  merged.snapshotDir = override.snapshotDir;
1245
1288
  }
1246
1289
  if ("config" in raw) merged.config = override.config;
1290
+ if ("cache" in raw) merged.cache = override.cache;
1247
1291
  if ("coverage" in raw) {
1248
1292
  merged.coverage = mergeCoverageConfig(
1249
1293
  merged.coverage,
@@ -1392,6 +1436,56 @@ export function applyMode(config, modeName) {
1392
1436
  function appendPathSegment(basePath, segment) {
1393
1437
  return join(basePath, segment);
1394
1438
  }
1439
+ // Parses a duration string ("500ms", "90s", "30m", "1h", "7d", "1.5h") to
1440
+ // milliseconds. Returns null when the format is unrecognized.
1441
+ export function parseDurationMs(value) {
1442
+ const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/.exec(value);
1443
+ if (!match) return null;
1444
+ const amount = Number(match[1]);
1445
+ const unit = match[2];
1446
+ const mult =
1447
+ unit === "ms"
1448
+ ? 1
1449
+ : unit === "s"
1450
+ ? 1000
1451
+ : unit === "m"
1452
+ ? 60000
1453
+ : unit === "h"
1454
+ ? 3600000
1455
+ : 86400000;
1456
+ return amount * mult;
1457
+ }
1458
+ // Resolves the effective cache mode + expiry for a run. CLI flags win over
1459
+ // config: --no-cache forces off, --cache forces full. The config value may be a
1460
+ // boolean, a mode string, or an object { type, maxTime }. Default is off
1461
+ // (opt-in). maxTime (entry expiry) comes from the object form regardless of the
1462
+ // resolved mode.
1463
+ export function resolveCacheSettings(configCache, flags) {
1464
+ if (flags.noCache) return { mode: "off", maxTimeMs: null };
1465
+ // "reachable" is accepted for back-compat but treated as "full": reachability
1466
+ // pruning was unsound for AssemblyScript (compile-time-inlined consts, static
1467
+ // fields, @inline bodies, and re-export barrels can change output without
1468
+ // being "reachable"), so it could serve stale results. The full dependency
1469
+ // set is always correct.
1470
+ let mode = "off";
1471
+ let maxTimeMs = null;
1472
+ if (configCache && typeof configCache === "object") {
1473
+ mode = configCache.type === "build" ? "build" : "full";
1474
+ maxTimeMs = configCache.maxTime
1475
+ ? parseDurationMs(configCache.maxTime)
1476
+ : null;
1477
+ } else if (
1478
+ configCache === true ||
1479
+ configCache === "full" ||
1480
+ configCache === "reachable"
1481
+ ) {
1482
+ mode = "full";
1483
+ } else if (configCache === "build") {
1484
+ mode = "build";
1485
+ }
1486
+ if (flags.cache) mode = "full"; // --cache forces full; maxTime (if any) kept
1487
+ return { mode, maxTimeMs };
1488
+ }
1395
1489
  export function getCliVersion() {
1396
1490
  const candidates = [
1397
1491
  join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "as-test",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "author": "Jairus Tanaka",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,7 +24,7 @@
24
24
  "husky": "^9.1.7",
25
25
  "playwright": "^1.60.0",
26
26
  "prettier": "3.8.3",
27
- "try-as": "^1.1.0",
27
+ "try-as": "1.1.0",
28
28
  "typescript": "^6.0.3",
29
29
  "typescript-eslint": "^8.59.4",
30
30
  "vitepress": "^1.6.4"
@@ -140,7 +140,7 @@ function analyzeSourceText(sourceText) {
140
140
  ? new RegExp(`\\b${escapeRegex(runAlias)}\\s*\\(`).test(text)
141
141
  : false;
142
142
  return {
143
- hasSuiteCalls: /\b(?:describe|test|it|only|xonly|todo|fuzz|xfuzz)\s*\(/.test(text),
143
+ hasSuiteCalls: /\b(?:x?describe|x?test|x?it|x?only|todo|x?fuzz)\s*\(/.test(text),
144
144
  hasRunCall,
145
145
  runImportPath,
146
146
  hasMockCalls: /\b(?:mockFn|unmockFn|mockImport|unmockImport)\s*\(/.test(text),
@@ -174,7 +174,7 @@ function detectRunAlias(text) {
174
174
  return null;
175
175
  }
176
176
  function looksLikeAsTestImport(specifiers) {
177
- return /\b(?:describe|test|it|only|xonly|todo|fuzz|xfuzz|expect|beforeAll|afterAll|beforeEach|afterEach|mockFn|unmockFn|mockImport|unmockImport|snapshotFn|log|run)\b/.test(specifiers);
177
+ return /\b(?:x?describe|x?test|x?it|x?only|todo|x?fuzz|expect|beforeAll|afterAll|beforeEach|afterEach|mockFn|unmockFn|mockImport|unmockImport|snapshotFn|log|run)\b/.test(specifiers);
178
178
  }
179
179
  function stripComments(sourceText) {
180
180
  return sourceText.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");