as-test 1.5.1 → 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.
@@ -0,0 +1,208 @@
1
+ import { glob } from "glob";
2
+ import chalk from "chalk";
3
+ import * as path from "path";
4
+ // Selector resolution runs in several places per command (the orchestrator,
5
+ // then build/run cores per file); dedupe by text so a warning prints once per
6
+ // process regardless of how many resolvers see the same selector.
7
+ const reportedSelectorWarnings = new Set();
8
+ export function emitSelectorWarnings(warnings) {
9
+ for (const warning of warnings) {
10
+ if (reportedSelectorWarnings.has(warning)) continue;
11
+ reportedSelectorWarnings.add(warning);
12
+ process.stderr.write(`${chalk.yellow.bold("WARN")} ${warning}\n`);
13
+ }
14
+ }
15
+ const GLOB_MAGIC = /[*?[\]{}]/;
16
+ function hasGlobMagic(selector) {
17
+ return GLOB_MAGIC.test(selector);
18
+ }
19
+ function endsWithSlash(selector) {
20
+ return /[\\/]$/.test(selector);
21
+ }
22
+ function stripTrailingSlash(selector) {
23
+ return selector.replace(/[\\/]+$/, "");
24
+ }
25
+ function stripSuiteSuffix(selector) {
26
+ return selector.replace(/\.spec\.ts$/, "").replace(/\.ts$/, "");
27
+ }
28
+ function isCwdRelative(selector) {
29
+ return (
30
+ selector.startsWith("./") ||
31
+ selector.startsWith("../") ||
32
+ selector.startsWith(".\\") ||
33
+ selector.startsWith("..\\") ||
34
+ selector.startsWith("/") ||
35
+ selector.startsWith("~") ||
36
+ path.isAbsolute(selector)
37
+ );
38
+ }
39
+ // A selector with a path separator that is not merely a single trailing slash
40
+ // (e.g. `assembly/__tests__/foo.spec.ts`, passed verbatim by the orchestrator)
41
+ // is treated as a direct cwd-relative path rather than a test-folder alias.
42
+ function hasInternalSlash(selector) {
43
+ return /[\\/]/.test(stripTrailingSlash(selector));
44
+ }
45
+ // The longest leading run of path segments containing no glob magic — the
46
+ // static "test folder" of an input pattern (`assembly/__tests__/**/*.spec.ts`
47
+ // -> `assembly/__tests__`).
48
+ function globBase(pattern) {
49
+ const segments = pattern.split("/");
50
+ const base = [];
51
+ for (const segment of segments) {
52
+ if (hasGlobMagic(segment)) break;
53
+ base.push(segment);
54
+ }
55
+ return base.join("/") || ".";
56
+ }
57
+ function uniqueInputRoots(configuredInputs) {
58
+ const roots = new Set();
59
+ for (const pattern of configuredInputs) {
60
+ if (pattern.startsWith("!")) continue;
61
+ roots.add(globBase(pattern));
62
+ }
63
+ return [...roots];
64
+ }
65
+ // Turn a cwd-relative selector into the spec glob(s) it stands for.
66
+ function cwdPatterns(selector) {
67
+ if (endsWithSlash(selector)) {
68
+ return [`${stripTrailingSlash(selector)}/**/*.spec.ts`];
69
+ }
70
+ if (/\.ts$/.test(selector)) return [selector];
71
+ return [`${stripSuiteSuffix(selector)}.spec.ts`];
72
+ }
73
+ // Turn a bare selector into the spec glob(s) it stands for, anchored to a
74
+ // configured input root and searched recursively beneath it. A selector that
75
+ // already carries glob magic (`rfc/*.spec.ts`, `*.spec.ts`) is appended
76
+ // verbatim so the user's pattern controls the match; a plain folder/name has
77
+ // the spec suffix supplied.
78
+ function barePatterns(root, selector) {
79
+ if (hasGlobMagic(selector)) {
80
+ return [`${root}/**/${selector}`];
81
+ }
82
+ if (endsWithSlash(selector)) {
83
+ return [`${root}/**/${stripTrailingSlash(selector)}/**/*.spec.ts`];
84
+ }
85
+ return [`${root}/**/${stripSuiteSuffix(selector)}.spec.ts`];
86
+ }
87
+ // Split comma-joined bare selectors (`a,b,c`) while leaving paths and globs
88
+ // (which can legitimately contain commas, e.g. `{a,b}`) intact.
89
+ function expandSelectors(selectors) {
90
+ const expanded = [];
91
+ for (const selector of selectors) {
92
+ if (!selector) continue;
93
+ if (
94
+ selector.includes(",") &&
95
+ !hasInternalSlash(selector) &&
96
+ !endsWithSlash(selector) &&
97
+ !hasGlobMagic(selector)
98
+ ) {
99
+ for (const token of selector.split(",")) {
100
+ const trimmed = token.trim();
101
+ if (trimmed.length) expanded.push(trimmed);
102
+ }
103
+ continue;
104
+ }
105
+ expanded.push(selector);
106
+ }
107
+ return expanded;
108
+ }
109
+ async function globFiles(patterns) {
110
+ return glob(patterns);
111
+ }
112
+ async function resolveSelector(selector, inputRoots) {
113
+ const warnings = [];
114
+ const isGlob = hasGlobMagic(selector);
115
+ // Explicit cwd-relative selector (`./`, `../`, absolute, `~`) — resolve from
116
+ // the cwd only. A glob is matched verbatim; a plain path gets the spec suffix.
117
+ if (isCwdRelative(selector)) {
118
+ const files = await globFiles(isGlob ? [selector] : cwdPatterns(selector));
119
+ if (!files.length) {
120
+ const bare = selector.replace(/^\.[\\/]/, "");
121
+ let suggestion = null;
122
+ for (const root of inputRoots) {
123
+ const inRoot = await globFiles(barePatterns(root, bare));
124
+ if (inRoot.length) {
125
+ suggestion = bare;
126
+ break;
127
+ }
128
+ }
129
+ warnings.push(
130
+ suggestion
131
+ ? `"${selector}" not found relative to the current directory — did you mean "${suggestion}" (searches the configured test folder)?`
132
+ : `"${selector}" not found relative to the current directory`,
133
+ );
134
+ }
135
+ return { files, warnings };
136
+ }
137
+ // A plain path with an internal separator (e.g. the orchestrator's own
138
+ // `assembly/__tests__/foo.spec.ts`) resolves from the cwd verbatim. Globs
139
+ // skip this and fall through to test-folder anchoring below.
140
+ if (!isGlob && hasInternalSlash(selector)) {
141
+ const direct = await globFiles(cwdPatterns(selector));
142
+ if (direct.length) return { files: direct, warnings };
143
+ // Fall through to test-folder resolution for user shorthands like
144
+ // `nested/array` that aren't a real cwd path.
145
+ }
146
+ // Bare name/folder or relative glob — configured input root(s) first.
147
+ const perRoot = [];
148
+ for (const root of inputRoots) {
149
+ const files = await globFiles(barePatterns(root, selector));
150
+ if (files.length) perRoot.push({ root, files });
151
+ }
152
+ if (perRoot.length) {
153
+ if (perRoot.length > 1) {
154
+ warnings.push(
155
+ `selector "${selector}" matched specs under ${perRoot.length} input roots (${perRoot
156
+ .map((entry) => entry.root)
157
+ .join(", ")}) — running all of them`,
158
+ );
159
+ }
160
+ return { files: perRoot.flatMap((entry) => entry.files), warnings };
161
+ }
162
+ // Fall back to the cwd before giving up.
163
+ const cwdFiles = await globFiles(
164
+ isGlob ? [selector] : cwdPatterns(`./${selector}`),
165
+ );
166
+ if (cwdFiles.length) {
167
+ return { files: cwdFiles, warnings };
168
+ }
169
+ warnings.push(
170
+ inputRoots.length
171
+ ? `no spec files matched "${selector}" in ${inputRoots.join(", ")} or the current directory`
172
+ : `no spec files matched "${selector}"`,
173
+ );
174
+ return { files: [], warnings };
175
+ }
176
+ // Resolve configured input patterns + positional selectors into the concrete
177
+ // set of spec files to act on, along with any human-readable warnings. With no
178
+ // selectors this is just the configured globs (honoring `!`-negations); with
179
+ // selectors the per-selector rules above apply and config negations are
180
+ // intentionally bypassed so an explicit pick always wins.
181
+ export async function resolveSpecFiles(configured, selectors) {
182
+ const configuredInputs = Array.isArray(configured)
183
+ ? configured
184
+ : [configured];
185
+ if (!selectors.length) {
186
+ const include = configuredInputs.filter((p) => !p.startsWith("!"));
187
+ const ignore = configuredInputs
188
+ .filter((p) => p.startsWith("!"))
189
+ .map((p) => p.slice(1));
190
+ const files = (await glob(include, { ignore })).sort((a, b) =>
191
+ a.localeCompare(b),
192
+ );
193
+ return { files, warnings: [] };
194
+ }
195
+ const inputRoots = uniqueInputRoots(configuredInputs);
196
+ const files = new Set();
197
+ const warnings = [];
198
+ for (const selector of expandSelectors(selectors)) {
199
+ if (!selector) continue;
200
+ const resolved = await resolveSelector(selector, inputRoots);
201
+ for (const file of resolved.files) files.add(file);
202
+ warnings.push(...resolved.warnings);
203
+ }
204
+ return {
205
+ files: [...files].sort((a, b) => a.localeCompare(b)),
206
+ warnings,
207
+ };
208
+ }
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.1",
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, "");