@glubean/scanner 0.4.0 → 0.5.1
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/dist/ast.d.ts +120 -0
- package/dist/ast.d.ts.map +1 -0
- package/dist/ast.js +312 -0
- package/dist/ast.js.map +1 -0
- package/dist/contract-extraction.d.ts +23 -0
- package/dist/contract-extraction.d.ts.map +1 -1
- package/dist/contract-extraction.js +39 -0
- package/dist/contract-extraction.js.map +1 -1
- package/dist/extractor-ast.d.ts +59 -0
- package/dist/extractor-ast.d.ts.map +1 -0
- package/dist/extractor-ast.js +622 -0
- package/dist/extractor-ast.js.map +1 -0
- package/dist/extractor-static.d.ts +22 -95
- package/dist/extractor-static.d.ts.map +1 -1
- package/dist/extractor-static.js +11 -753
- package/dist/extractor-static.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +2 -1
- package/dist/scanner.js.map +1 -1
- package/dist/static.d.ts +3 -2
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +6 -1
- package/dist/static.js.map +1 -1
- package/package.json +9 -1
package/dist/extractor-static.js
CHANGED
|
@@ -1,23 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Static
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* **Limitations:**
|
|
13
|
-
* - Template variables (`$id`, `$_pick`) in IDs are preserved as-is, not resolved.
|
|
14
|
-
* - Dynamically computed IDs or tags are not detected.
|
|
15
|
-
* - `test.each()` / `test.pick()` produce one ExportMeta with the template ID,
|
|
16
|
-
* not one per data row (row count is unknown statically).
|
|
17
|
-
* - Deeply nested or multi-line object literals with complex expressions may
|
|
18
|
-
* not be fully parsed.
|
|
2
|
+
* Static guards + shared static-metadata types for Glubean scanning.
|
|
3
|
+
*
|
|
4
|
+
* The test/contract/pick EXTRACTORS that used to live here are gone — they are
|
|
5
|
+
* now AST-based (@babel/parser) in `extractor-ast.ts`. What remains are the two
|
|
6
|
+
* cheap regex GUARDS that run over every candidate file in phase 1 (no TS
|
|
7
|
+
* expression parsing, so a parser would only add cost):
|
|
8
|
+
* - `isGlubeanFile` — does this file import the SDK / a test-like function?
|
|
9
|
+
* - `extractAliasesFromSource` — `const x = test.extend(...)` alias names.
|
|
10
|
+
* Plus the result types consumed across packages (`ExportMeta` lives in
|
|
11
|
+
* `types.ts`; `PickMeta` / `Contract*StaticMeta` live here).
|
|
19
12
|
*/
|
|
20
|
-
import { resolveDataPath } from "./data-path.js";
|
|
21
13
|
// ---------------------------------------------------------------------------
|
|
22
14
|
// SDK import detection
|
|
23
15
|
// ---------------------------------------------------------------------------
|
|
@@ -50,7 +42,7 @@ function buildFnAlternation(customFns) {
|
|
|
50
42
|
/**
|
|
51
43
|
* Check if a file's content looks like a Glubean test/task file.
|
|
52
44
|
*
|
|
53
|
-
*
|
|
45
|
+
* A fast, parse-free guard run before the (AST) extractors.
|
|
54
46
|
*
|
|
55
47
|
* Detection layers (any match → true):
|
|
56
48
|
* 1. Direct SDK module import (`jsr:@glubean/sdk`, `@glubean/sdk`)
|
|
@@ -141,264 +133,6 @@ function stripComments(source) {
|
|
|
141
133
|
return result;
|
|
142
134
|
}
|
|
143
135
|
// ---------------------------------------------------------------------------
|
|
144
|
-
// Parsing helpers
|
|
145
|
-
// ---------------------------------------------------------------------------
|
|
146
|
-
/** Count newlines before `offset` to compute 1-based line number. */
|
|
147
|
-
function getLineNumber(content, offset) {
|
|
148
|
-
let line = 1;
|
|
149
|
-
for (let i = 0; i < offset && i < content.length; i++) {
|
|
150
|
-
if (content[i] === "\n")
|
|
151
|
-
line++;
|
|
152
|
-
}
|
|
153
|
-
return line;
|
|
154
|
-
}
|
|
155
|
-
/**
|
|
156
|
-
* Find the index of the matching closing bracket starting from `startIndex`
|
|
157
|
-
* (which must point to the opening bracket). Respects string boundaries.
|
|
158
|
-
* Returns -1 if no match is found.
|
|
159
|
-
*/
|
|
160
|
-
function findMatching(source, startIndex, open, close) {
|
|
161
|
-
let depth = 0;
|
|
162
|
-
let inString = false;
|
|
163
|
-
let stringChar = "";
|
|
164
|
-
for (let i = startIndex; i < source.length; i++) {
|
|
165
|
-
const ch = source[i];
|
|
166
|
-
if (inString) {
|
|
167
|
-
if (ch === "\\" && i + 1 < source.length) {
|
|
168
|
-
i++; // skip escaped char
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
if (ch === stringChar)
|
|
172
|
-
inString = false;
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
if (ch === '"' || ch === "'" || ch === "`") {
|
|
176
|
-
inString = true;
|
|
177
|
-
stringChar = ch;
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
if (ch === open)
|
|
181
|
-
depth++;
|
|
182
|
-
if (ch === close) {
|
|
183
|
-
depth--;
|
|
184
|
-
if (depth === 0)
|
|
185
|
-
return i;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return -1;
|
|
189
|
-
}
|
|
190
|
-
/** Shorthand: find closing `)` for an opening `(`. */
|
|
191
|
-
function findCloseParen(source, openIndex) {
|
|
192
|
-
return findMatching(source, openIndex, "(", ")");
|
|
193
|
-
}
|
|
194
|
-
/** Shorthand: find closing `}` for an opening `{`. */
|
|
195
|
-
function findCloseBrace(source, openIndex) {
|
|
196
|
-
return findMatching(source, openIndex, "{", "}");
|
|
197
|
-
}
|
|
198
|
-
// ---------------------------------------------------------------------------
|
|
199
|
-
// Metadata extraction from object literals
|
|
200
|
-
// ---------------------------------------------------------------------------
|
|
201
|
-
/**
|
|
202
|
-
* Parse `id`, `name`, `tags`, and `timeout` from a TestMeta-like object literal string.
|
|
203
|
-
* Handles both `tags: ["a", "b"]` and `tags: "a"` forms, with single or double quotes.
|
|
204
|
-
*/
|
|
205
|
-
function parseMetaObject(source) {
|
|
206
|
-
const result = {};
|
|
207
|
-
const idMatch = source.match(/id:\s*(['"])([^'"]+)\1/);
|
|
208
|
-
if (idMatch)
|
|
209
|
-
result.id = idMatch[2];
|
|
210
|
-
const nameMatch = source.match(/name:\s*(['"])([^'"]+)\1/);
|
|
211
|
-
if (nameMatch)
|
|
212
|
-
result.name = nameMatch[2];
|
|
213
|
-
// Tags as array: tags: ["smoke", "auth"] or tags: ['smoke', 'auth']
|
|
214
|
-
const tagsArrayMatch = source.match(/tags:\s*\[([^\]]*)\]/);
|
|
215
|
-
if (tagsArrayMatch) {
|
|
216
|
-
result.tags = [...tagsArrayMatch[1].matchAll(/(['"])([^'"]+)\1/g)].map((m) => m[2]);
|
|
217
|
-
}
|
|
218
|
-
else {
|
|
219
|
-
// Tags as single string: tags: "smoke" or tags: 'smoke'
|
|
220
|
-
const tagsStringMatch = source.match(/tags:\s*(['"])([^'"]+)\1/);
|
|
221
|
-
if (tagsStringMatch)
|
|
222
|
-
result.tags = [tagsStringMatch[2]];
|
|
223
|
-
}
|
|
224
|
-
const timeoutMatch = source.match(/timeout:\s*(\d+)/);
|
|
225
|
-
if (timeoutMatch)
|
|
226
|
-
result.timeout = Number(timeoutMatch[1]);
|
|
227
|
-
const requiresMatch = source.match(/requires:\s*(['"])(headless|browser|out-of-band)\1/);
|
|
228
|
-
if (requiresMatch) {
|
|
229
|
-
result.requires = requiresMatch[2];
|
|
230
|
-
}
|
|
231
|
-
const defaultRunMatch = source.match(/defaultRun:\s*(['"])(always|opt-in)\1/);
|
|
232
|
-
if (defaultRunMatch) {
|
|
233
|
-
result.defaultRun = defaultRunMatch[2];
|
|
234
|
-
}
|
|
235
|
-
return result;
|
|
236
|
-
}
|
|
237
|
-
/**
|
|
238
|
-
* Extract `name` and `tags` from a `.meta({...})` builder call within `scope`.
|
|
239
|
-
*/
|
|
240
|
-
function extractBuilderMeta(scope) {
|
|
241
|
-
// Caller (parseTestDeclaration) already bounds `scope` to the text
|
|
242
|
-
// AFTER the test() call's closing paren, so we only see the builder
|
|
243
|
-
// chain. Anchor on either start-of-scope (so the first chained
|
|
244
|
-
// `.meta(...)` matches when the test takes no callback, e.g.
|
|
245
|
-
// `test("id").meta(...)`) or a preceding `)` (e.g.
|
|
246
|
-
// `test("id").step(...).meta(...)`). This belt-and-suspenders defense
|
|
247
|
-
// also protects callers that pass a wider scope.
|
|
248
|
-
const match = scope.match(/(?:^|\))\s*\.\s*meta\s*\(\s*\{/);
|
|
249
|
-
if (!match || match.index === undefined)
|
|
250
|
-
return {};
|
|
251
|
-
const braceStart = scope.indexOf("{", match.index);
|
|
252
|
-
const braceEnd = findCloseBrace(scope, braceStart);
|
|
253
|
-
if (braceEnd === -1)
|
|
254
|
-
return {};
|
|
255
|
-
const obj = scope.substring(braceStart, braceEnd + 1);
|
|
256
|
-
return parseMetaObject(obj);
|
|
257
|
-
}
|
|
258
|
-
/**
|
|
259
|
-
* Extract step names from `.step("name", ...)` or `.step('name', ...)` chains within `scope`.
|
|
260
|
-
*/
|
|
261
|
-
function extractSteps(scope) {
|
|
262
|
-
const steps = [];
|
|
263
|
-
const stepPattern = /\.step\(\s*(['"])([^'"]+)\1/g;
|
|
264
|
-
let m;
|
|
265
|
-
while ((m = stepPattern.exec(scope)) !== null) {
|
|
266
|
-
steps.push({ name: m[2] });
|
|
267
|
-
}
|
|
268
|
-
return steps;
|
|
269
|
-
}
|
|
270
|
-
// ---------------------------------------------------------------------------
|
|
271
|
-
// Declaration parser
|
|
272
|
-
// ---------------------------------------------------------------------------
|
|
273
|
-
/**
|
|
274
|
-
* Parse a single test declaration from the text that follows `test` in
|
|
275
|
-
* `export const NAME = test<scope>`. Returns null if the pattern is not
|
|
276
|
-
* recognized.
|
|
277
|
-
*/
|
|
278
|
-
function parseTestDeclaration(scope, exportName, line) {
|
|
279
|
-
let rest = scope;
|
|
280
|
-
let variant;
|
|
281
|
-
// Check for .each() or .pick() — may appear on same line or next line
|
|
282
|
-
let parallel = false;
|
|
283
|
-
const dataMatch = rest.match(/^\s*\.\s*(each|pick)\s*\(/);
|
|
284
|
-
if (dataMatch) {
|
|
285
|
-
variant = dataMatch[1];
|
|
286
|
-
const openIndex = rest.indexOf("(", dataMatch.index);
|
|
287
|
-
const closeIndex = findCloseParen(rest, openIndex);
|
|
288
|
-
if (closeIndex === -1)
|
|
289
|
-
return null;
|
|
290
|
-
// Check for { parallel: true } in .each() args
|
|
291
|
-
if (variant === "each") {
|
|
292
|
-
const eachArgs = rest.substring(openIndex + 1, closeIndex);
|
|
293
|
-
if (/parallel\s*:\s*true/.test(eachArgs)) {
|
|
294
|
-
parallel = true;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
rest = rest.substring(closeIndex + 1);
|
|
298
|
-
}
|
|
299
|
-
// Expect opening paren of the test call: test( or test.each(...)( or <generic>test<T>(
|
|
300
|
-
const callMatch = rest.match(/^\s*(?:<[^>]*>)?\s*\(/);
|
|
301
|
-
if (!callMatch)
|
|
302
|
-
return null;
|
|
303
|
-
const callOpenIndex = rest.indexOf("(", callMatch.index);
|
|
304
|
-
// Bound the builder-chain search to text AFTER the test() call closes
|
|
305
|
-
// AND BEFORE the test statement ends (first depth-0 semicolon). Without
|
|
306
|
-
// both bounds `scope` runs until the next export and could pick up a
|
|
307
|
-
// sibling `foo().meta({ requires: "browser" })` between this test and
|
|
308
|
-
// the next export, mis-attributing capability metadata.
|
|
309
|
-
const callCloseIndex = findCloseParen(rest, callOpenIndex);
|
|
310
|
-
let builderChainScope = "";
|
|
311
|
-
if (callCloseIndex !== -1) {
|
|
312
|
-
const chainStart = callCloseIndex + 1;
|
|
313
|
-
let depth = 0;
|
|
314
|
-
let chainEnd = rest.length;
|
|
315
|
-
for (let i = chainStart; i < rest.length; i++) {
|
|
316
|
-
const c = rest[i];
|
|
317
|
-
if (c === "(" || c === "{" || c === "[")
|
|
318
|
-
depth++;
|
|
319
|
-
else if (c === ")" || c === "}" || c === "]")
|
|
320
|
-
depth--;
|
|
321
|
-
else if (c === ";" && depth === 0) {
|
|
322
|
-
chainEnd = i;
|
|
323
|
-
break;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
builderChainScope = rest.substring(chainStart, chainEnd);
|
|
327
|
-
}
|
|
328
|
-
const afterOpen = rest.substring(callOpenIndex + 1).trimStart();
|
|
329
|
-
let id;
|
|
330
|
-
let name;
|
|
331
|
-
let tags;
|
|
332
|
-
let timeout;
|
|
333
|
-
let requires;
|
|
334
|
-
let defaultRun;
|
|
335
|
-
if (afterOpen.startsWith('"') || afterOpen.startsWith("'")) {
|
|
336
|
-
// String ID
|
|
337
|
-
const quote = afterOpen[0];
|
|
338
|
-
const endQuote = afterOpen.indexOf(quote, 1);
|
|
339
|
-
if (endQuote === -1)
|
|
340
|
-
return null;
|
|
341
|
-
id = afterOpen.substring(1, endQuote);
|
|
342
|
-
}
|
|
343
|
-
else if (afterOpen.startsWith("{")) {
|
|
344
|
-
// TestMeta object
|
|
345
|
-
const braceEnd = findCloseBrace(afterOpen, 0);
|
|
346
|
-
if (braceEnd === -1)
|
|
347
|
-
return null;
|
|
348
|
-
const objStr = afterOpen.substring(0, braceEnd + 1);
|
|
349
|
-
const parsed = parseMetaObject(objStr);
|
|
350
|
-
id = parsed.id;
|
|
351
|
-
name = parsed.name;
|
|
352
|
-
tags = parsed.tags;
|
|
353
|
-
timeout = parsed.timeout;
|
|
354
|
-
requires = parsed.requires;
|
|
355
|
-
defaultRun = parsed.defaultRun;
|
|
356
|
-
}
|
|
357
|
-
if (!id)
|
|
358
|
-
return null;
|
|
359
|
-
// Extract builder .meta({...}) ONLY from the chain after the test()
|
|
360
|
-
// call closes — not from inside the callback body.
|
|
361
|
-
const builderMeta = extractBuilderMeta(builderChainScope);
|
|
362
|
-
if (!name && builderMeta.name)
|
|
363
|
-
name = builderMeta.name;
|
|
364
|
-
if (!tags && builderMeta.tags)
|
|
365
|
-
tags = builderMeta.tags;
|
|
366
|
-
if (timeout === undefined && builderMeta.timeout !== undefined) {
|
|
367
|
-
timeout = builderMeta.timeout;
|
|
368
|
-
}
|
|
369
|
-
if (requires === undefined && builderMeta.requires !== undefined) {
|
|
370
|
-
requires = builderMeta.requires;
|
|
371
|
-
}
|
|
372
|
-
if (defaultRun === undefined && builderMeta.defaultRun !== undefined) {
|
|
373
|
-
defaultRun = builderMeta.defaultRun;
|
|
374
|
-
}
|
|
375
|
-
// Extract .step("name", ...) chains from the full scope
|
|
376
|
-
const steps = extractSteps(scope);
|
|
377
|
-
const result = {
|
|
378
|
-
type: "test",
|
|
379
|
-
id,
|
|
380
|
-
exportName,
|
|
381
|
-
location: { line, col: 1 },
|
|
382
|
-
};
|
|
383
|
-
if (name)
|
|
384
|
-
result.name = name;
|
|
385
|
-
if (tags && tags.length > 0)
|
|
386
|
-
result.tags = tags;
|
|
387
|
-
if (timeout !== undefined)
|
|
388
|
-
result.timeout = timeout;
|
|
389
|
-
if (requires !== undefined)
|
|
390
|
-
result.requires = requires;
|
|
391
|
-
if (defaultRun !== undefined)
|
|
392
|
-
result.defaultRun = defaultRun;
|
|
393
|
-
if (variant)
|
|
394
|
-
result.variant = variant;
|
|
395
|
-
if (steps.length > 0)
|
|
396
|
-
result.steps = steps;
|
|
397
|
-
if (parallel)
|
|
398
|
-
result.parallel = true;
|
|
399
|
-
return result;
|
|
400
|
-
}
|
|
401
|
-
// ---------------------------------------------------------------------------
|
|
402
136
|
// Alias discovery (auto-detect test.extend / task.extend)
|
|
403
137
|
// ---------------------------------------------------------------------------
|
|
404
138
|
/**
|
|
@@ -426,480 +160,4 @@ export function extractAliasesFromSource(content) {
|
|
|
426
160
|
}
|
|
427
161
|
return aliases;
|
|
428
162
|
}
|
|
429
|
-
// ---------------------------------------------------------------------------
|
|
430
|
-
// Public API
|
|
431
|
-
// ---------------------------------------------------------------------------
|
|
432
|
-
/**
|
|
433
|
-
* Extract test metadata from TypeScript source using static analysis (regex).
|
|
434
|
-
*
|
|
435
|
-
* Recognizes the following patterns:
|
|
436
|
-
* - `export const x = test("id", fn)` — simple test with string ID
|
|
437
|
-
* - `export const x = test({ id, name, tags }, fn)` — simple test with meta
|
|
438
|
-
* - `export const x = test("id").step(...)` — builder with steps
|
|
439
|
-
* - `export const x = test.each(data)("id-$key", fn)` — data-driven
|
|
440
|
-
* - `export const x = test.pick(examples)("id-$_pick", fn)` — example selection
|
|
441
|
-
*
|
|
442
|
-
* This is a pure function — no file system or runtime access needed.
|
|
443
|
-
*
|
|
444
|
-
* @param content - TypeScript source code
|
|
445
|
-
* @param customFns - Additional function names discovered via `extractAliasesFromSource`.
|
|
446
|
-
* When provided, these names are matched alongside `test` and `task`.
|
|
447
|
-
* When omitted, falls back to `*Test` / `*Task` convention matching.
|
|
448
|
-
* @returns Array of extracted export metadata
|
|
449
|
-
*/
|
|
450
|
-
export function extractFromSource(content, customFns) {
|
|
451
|
-
const results = [];
|
|
452
|
-
const stripped = stripComments(content);
|
|
453
|
-
// Build the function-name alternation — either explicit aliases or convention fallback
|
|
454
|
-
const alt = buildFnAlternation(customFns);
|
|
455
|
-
const exportPattern = new RegExp(`export\\s+const\\s+(\\w+)\\s*=\\s*(${alt})\\b`, "g");
|
|
456
|
-
const matches = [];
|
|
457
|
-
let m;
|
|
458
|
-
while ((m = exportPattern.exec(stripped)) !== null) {
|
|
459
|
-
matches.push({
|
|
460
|
-
exportName: m[1],
|
|
461
|
-
offset: m.index,
|
|
462
|
-
afterTest: m.index + m[0].length,
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
for (let i = 0; i < matches.length; i++) {
|
|
466
|
-
const { exportName, offset, afterTest } = matches[i];
|
|
467
|
-
// Scope from right after the function name to the start of the next export (or EOF)
|
|
468
|
-
const endOffset = i + 1 < matches.length ? matches[i + 1].offset : stripped.length;
|
|
469
|
-
const scope = stripped.substring(afterTest, endOffset);
|
|
470
|
-
const line = getLineNumber(stripped, offset);
|
|
471
|
-
const meta = parseTestDeclaration(scope, exportName, line);
|
|
472
|
-
if (meta)
|
|
473
|
-
results.push(meta);
|
|
474
|
-
}
|
|
475
|
-
return results;
|
|
476
|
-
}
|
|
477
|
-
/**
|
|
478
|
-
* Create a static metadata extractor that uses file system to read content.
|
|
479
|
-
*
|
|
480
|
-
* Aliases can be supplied at two levels:
|
|
481
|
-
* - `customFns` (construction-time): baked-in aliases known upfront.
|
|
482
|
-
* - `runtimeFns` (call-time): aliases discovered during a Scanner two-phase
|
|
483
|
-
* scan. These are merged with `customFns` so the extractor benefits from
|
|
484
|
-
* aliases discovered after construction.
|
|
485
|
-
*
|
|
486
|
-
* @param readFile - Function to read file content as string
|
|
487
|
-
* @param customFns - Additional function names (from alias discovery)
|
|
488
|
-
* @returns MetadataExtractor function
|
|
489
|
-
*/
|
|
490
|
-
export function createStaticExtractor(readFile, customFns) {
|
|
491
|
-
return async (filePath, runtimeFns) => {
|
|
492
|
-
const content = await readFile(filePath);
|
|
493
|
-
// Merge construction-time and call-time aliases
|
|
494
|
-
const merged = customFns || runtimeFns ? [...new Set([...(customFns ?? []), ...(runtimeFns ?? [])])] : undefined;
|
|
495
|
-
return extractFromSource(content, merged);
|
|
496
|
-
};
|
|
497
|
-
}
|
|
498
|
-
/**
|
|
499
|
-
* Extract test.pick() metadata from TypeScript source for CodeLens rendering.
|
|
500
|
-
*
|
|
501
|
-
* Handles data source patterns:
|
|
502
|
-
* 1. Inline object literal: `test.pick({ "key1": ..., "key2": ... })`
|
|
503
|
-
* 2. JSON import variable: `import X from "./data.json"` then `test.pick(X)`
|
|
504
|
-
* 3. fromDir.merge variable: `const X = await fromDir.merge("./dir/")` then `test.pick(X)`
|
|
505
|
-
* 4. fromDir variable: `const X = await fromDir("./dir/")` then `test.pick(X)`
|
|
506
|
-
* 5. fromDir.concat variable: `const X = await fromDir.concat("./dir/")` then `test.pick(X)`
|
|
507
|
-
* 6. fromYaml.map variable: `const X = await fromYaml.map("./file.yaml")` then `test.pick(X)`
|
|
508
|
-
* 7. fromJson variable: `const X = await fromJson("./file.json")` then `test.each(X)`
|
|
509
|
-
* 8. fromJson.map variable: `const X = await fromJson.map("./file.json")` then `test.pick(X)`
|
|
510
|
-
*
|
|
511
|
-
* For other patterns (dynamic vars, etc.), returns keys: null.
|
|
512
|
-
*
|
|
513
|
-
* @param content - TypeScript source code
|
|
514
|
-
* @param options - Optional settings
|
|
515
|
-
* @param options.customFns - Additional function names discovered via alias scanning.
|
|
516
|
-
* @param options.filePath - Source file path. When provided, file-relative
|
|
517
|
-
* paths are resolved against this file's directory.
|
|
518
|
-
* @param options.projectRoot - Project root. When provided, bare paths are
|
|
519
|
-
* resolved against the project root instead of
|
|
520
|
-
* the source file directory.
|
|
521
|
-
* @returns Array of PickMeta, or empty if no test.pick calls found
|
|
522
|
-
*/
|
|
523
|
-
export function extractPickExamples(content, options) {
|
|
524
|
-
const customFns = options?.customFns;
|
|
525
|
-
const filePath = options?.filePath;
|
|
526
|
-
const projectRoot = options?.projectRoot;
|
|
527
|
-
const results = [];
|
|
528
|
-
// Build function-name alternation for pick patterns
|
|
529
|
-
const fnAlt = customFns && customFns.length > 0
|
|
530
|
-
? [...new Set(["test", "task", ...customFns])].map(s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")
|
|
531
|
-
: "\\w*(?:Test|Task)|test|task";
|
|
532
|
-
// Build a map of JSON imports: variable name → file path
|
|
533
|
-
const jsonImports = new Map();
|
|
534
|
-
const importPattern = /import\s+(\w+)\s+from\s+["']([^"']+\.json)["']/g;
|
|
535
|
-
let importMatch;
|
|
536
|
-
while ((importMatch = importPattern.exec(content)) !== null) {
|
|
537
|
-
jsonImports.set(importMatch[1], importMatch[2]);
|
|
538
|
-
}
|
|
539
|
-
// Build a map of fromDir.merge assignments: variable name → directory path
|
|
540
|
-
const dirMergeSources = new Map();
|
|
541
|
-
const dirMergePattern = /(?:const|let)\s+(\w+)\s*=\s*await\s+fromDir\.merge\s*(?:<[^>]*>)?\s*\(\s*["']([^"']+)["']/g;
|
|
542
|
-
let dirMergeMatch;
|
|
543
|
-
while ((dirMergeMatch = dirMergePattern.exec(content)) !== null) {
|
|
544
|
-
dirMergeSources.set(dirMergeMatch[1], dirMergeMatch[2]);
|
|
545
|
-
}
|
|
546
|
-
// Build a map of fromDir assignments: variable name → directory path
|
|
547
|
-
const dirSources = new Map();
|
|
548
|
-
const dirPattern = /(?:const|let)\s+(\w+)\s*=\s*await\s+fromDir\s*(?:<[^>]*>)?\s*\(\s*["']([^"']+)["']/g;
|
|
549
|
-
let dirMatch;
|
|
550
|
-
while ((dirMatch = dirPattern.exec(content)) !== null) {
|
|
551
|
-
// Exclude fromDir.merge and fromDir.concat which are already matched
|
|
552
|
-
const fullMatch = dirMatch[0];
|
|
553
|
-
if (!fullMatch.includes("fromDir.merge") && !fullMatch.includes("fromDir.concat")) {
|
|
554
|
-
dirSources.set(dirMatch[1], dirMatch[2]);
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
// Build a map of fromDir.concat assignments: variable name → directory path
|
|
558
|
-
const dirConcatSources = new Map();
|
|
559
|
-
const dirConcatPattern = /(?:const|let)\s+(\w+)\s*=\s*await\s+fromDir\.concat\s*(?:<[^>]*>)?\s*\(\s*["']([^"']+)["']/g;
|
|
560
|
-
let dirConcatMatch;
|
|
561
|
-
while ((dirConcatMatch = dirConcatPattern.exec(content)) !== null) {
|
|
562
|
-
dirConcatSources.set(dirConcatMatch[1], dirConcatMatch[2]);
|
|
563
|
-
}
|
|
564
|
-
// Build a map of fromYaml.map assignments: variable name → file path
|
|
565
|
-
const yamlMapSources = new Map();
|
|
566
|
-
const yamlMapPattern = /(?:const|let)\s+(\w+)\s*=\s*await\s+fromYaml\.map\s*(?:<[^>]*>)?\s*\(\s*["']([^"']+)["']/g;
|
|
567
|
-
let yamlMapMatch;
|
|
568
|
-
while ((yamlMapMatch = yamlMapPattern.exec(content)) !== null) {
|
|
569
|
-
yamlMapSources.set(yamlMapMatch[1], yamlMapMatch[2]);
|
|
570
|
-
}
|
|
571
|
-
// Build a map of fromJson assignments: variable name → file path
|
|
572
|
-
const jsonLoaderSources = new Map();
|
|
573
|
-
const jsonLoaderPattern = /(?:const|let)\s+(\w+)\s*=\s*await\s+fromJson\s*(?:<[^>]*>)?\s*\(\s*["']([^"']+)["']/g;
|
|
574
|
-
let jsonLoaderMatch;
|
|
575
|
-
while ((jsonLoaderMatch = jsonLoaderPattern.exec(content)) !== null) {
|
|
576
|
-
// Exclude fromJson.map which is matched separately
|
|
577
|
-
if (!jsonLoaderMatch[0].includes("fromJson.map")) {
|
|
578
|
-
jsonLoaderSources.set(jsonLoaderMatch[1], jsonLoaderMatch[2]);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
// Build a map of fromJson.map assignments: variable name → file path
|
|
582
|
-
const jsonMapSources = new Map();
|
|
583
|
-
const jsonMapPattern = /(?:const|let)\s+(\w+)\s*=\s*await\s+fromJson\.map\s*(?:<[^>]*>)?\s*\(\s*["']([^"']+)["']/g;
|
|
584
|
-
let jsonMapMatch;
|
|
585
|
-
while ((jsonMapMatch = jsonMapPattern.exec(content)) !== null) {
|
|
586
|
-
jsonMapSources.set(jsonMapMatch[1], jsonMapMatch[2]);
|
|
587
|
-
}
|
|
588
|
-
// ── Pattern 1: Inline object literal ────────────────────────────────────
|
|
589
|
-
const inlinePickPattern = new RegExp(`export\\s+const\\s+(\\w+)\\s*=\\s*(?:${fnAlt})\\s*\\.pick\\s*\\(\\s*\\{([\\s\\S]*?)\\}\\s*\\)\\s*\\(\\s*(?:["']([^"']+)["']|\\{\\s*id:\\s*["']([^"']+)["'])`, "g");
|
|
590
|
-
let match;
|
|
591
|
-
while ((match = inlinePickPattern.exec(content)) !== null) {
|
|
592
|
-
const exportName = match[1];
|
|
593
|
-
const objectBody = match[2];
|
|
594
|
-
const testId = match[3] ?? match[4];
|
|
595
|
-
const line = getLineNumber(content, match.index);
|
|
596
|
-
const keys = [];
|
|
597
|
-
let depth = 0;
|
|
598
|
-
for (let i = 0; i < objectBody.length; i++) {
|
|
599
|
-
const ch = objectBody[i];
|
|
600
|
-
if (ch === "{" || ch === "[") {
|
|
601
|
-
depth++;
|
|
602
|
-
}
|
|
603
|
-
else if (ch === "}" || ch === "]") {
|
|
604
|
-
depth--;
|
|
605
|
-
}
|
|
606
|
-
else if (depth === 0) {
|
|
607
|
-
const remaining = objectBody.slice(i);
|
|
608
|
-
const keyMatch = remaining.match(/^(?:["']([^"']+)["']|([a-zA-Z_]\w*))\s*:/);
|
|
609
|
-
if (keyMatch) {
|
|
610
|
-
keys.push(keyMatch[1] || keyMatch[2]);
|
|
611
|
-
i += keyMatch[0].length - 1;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
results.push({
|
|
616
|
-
testId,
|
|
617
|
-
line,
|
|
618
|
-
exportName,
|
|
619
|
-
keys: keys.length > 0 ? keys : null,
|
|
620
|
-
dataSource: keys.length > 0 ? { type: "inline" } : undefined,
|
|
621
|
-
});
|
|
622
|
-
}
|
|
623
|
-
// ── Pattern 2: Variable reference ────────────────────────────────────────
|
|
624
|
-
const varPickPattern = new RegExp(`export\\s+const\\s+(\\w+)\\s*=\\s*(?:${fnAlt})\\s*\\.pick\\s*\\(\\s*(\\w+)\\s*\\)\\s*\\(\\s*(?:["']([^"']+)["']|\\{\\s*id:\\s*["']([^"']+)["'])`, "g");
|
|
625
|
-
while ((match = varPickPattern.exec(content)) !== null) {
|
|
626
|
-
const exportName = match[1];
|
|
627
|
-
const varName = match[2];
|
|
628
|
-
const testId = match[3] ?? match[4];
|
|
629
|
-
const line = getLineNumber(content, match.index);
|
|
630
|
-
// Check JSON import
|
|
631
|
-
const jsonPath = jsonImports.get(varName);
|
|
632
|
-
if (jsonPath) {
|
|
633
|
-
results.push({
|
|
634
|
-
testId,
|
|
635
|
-
line,
|
|
636
|
-
exportName,
|
|
637
|
-
keys: null,
|
|
638
|
-
dataSource: {
|
|
639
|
-
type: "json-import",
|
|
640
|
-
path: resolveDataPath(jsonPath, {
|
|
641
|
-
filePath,
|
|
642
|
-
projectRoot,
|
|
643
|
-
}).resolvedPath,
|
|
644
|
-
},
|
|
645
|
-
});
|
|
646
|
-
continue;
|
|
647
|
-
}
|
|
648
|
-
// Check fromDir.merge
|
|
649
|
-
const dirMergePath = dirMergeSources.get(varName);
|
|
650
|
-
if (dirMergePath) {
|
|
651
|
-
results.push({
|
|
652
|
-
testId,
|
|
653
|
-
line,
|
|
654
|
-
exportName,
|
|
655
|
-
keys: null,
|
|
656
|
-
dataSource: {
|
|
657
|
-
type: "dir-merge",
|
|
658
|
-
path: resolveDataPath(dirMergePath, {
|
|
659
|
-
filePath,
|
|
660
|
-
projectRoot,
|
|
661
|
-
}).resolvedPath,
|
|
662
|
-
},
|
|
663
|
-
});
|
|
664
|
-
continue;
|
|
665
|
-
}
|
|
666
|
-
// Check fromDir
|
|
667
|
-
const dirPathVal = dirSources.get(varName);
|
|
668
|
-
if (dirPathVal) {
|
|
669
|
-
results.push({
|
|
670
|
-
testId,
|
|
671
|
-
line,
|
|
672
|
-
exportName,
|
|
673
|
-
keys: null,
|
|
674
|
-
dataSource: {
|
|
675
|
-
type: "dir",
|
|
676
|
-
path: resolveDataPath(dirPathVal, {
|
|
677
|
-
filePath,
|
|
678
|
-
projectRoot,
|
|
679
|
-
}).resolvedPath,
|
|
680
|
-
},
|
|
681
|
-
});
|
|
682
|
-
continue;
|
|
683
|
-
}
|
|
684
|
-
// Check fromDir.concat
|
|
685
|
-
const dirConcatPath = dirConcatSources.get(varName);
|
|
686
|
-
if (dirConcatPath) {
|
|
687
|
-
results.push({
|
|
688
|
-
testId,
|
|
689
|
-
line,
|
|
690
|
-
exportName,
|
|
691
|
-
keys: null,
|
|
692
|
-
dataSource: {
|
|
693
|
-
type: "dir-concat",
|
|
694
|
-
path: resolveDataPath(dirConcatPath, {
|
|
695
|
-
filePath,
|
|
696
|
-
projectRoot,
|
|
697
|
-
}).resolvedPath,
|
|
698
|
-
},
|
|
699
|
-
});
|
|
700
|
-
continue;
|
|
701
|
-
}
|
|
702
|
-
// Check fromYaml.map
|
|
703
|
-
const yamlMapPath = yamlMapSources.get(varName);
|
|
704
|
-
if (yamlMapPath) {
|
|
705
|
-
results.push({
|
|
706
|
-
testId,
|
|
707
|
-
line,
|
|
708
|
-
exportName,
|
|
709
|
-
keys: null,
|
|
710
|
-
dataSource: {
|
|
711
|
-
type: "yaml-map",
|
|
712
|
-
path: resolveDataPath(yamlMapPath, {
|
|
713
|
-
filePath,
|
|
714
|
-
projectRoot,
|
|
715
|
-
}).resolvedPath,
|
|
716
|
-
},
|
|
717
|
-
});
|
|
718
|
-
continue;
|
|
719
|
-
}
|
|
720
|
-
// Check fromJson
|
|
721
|
-
const jsonLoaderPath = jsonLoaderSources.get(varName);
|
|
722
|
-
if (jsonLoaderPath) {
|
|
723
|
-
results.push({
|
|
724
|
-
testId,
|
|
725
|
-
line,
|
|
726
|
-
exportName,
|
|
727
|
-
keys: null,
|
|
728
|
-
dataSource: {
|
|
729
|
-
type: "json-loader",
|
|
730
|
-
path: resolveDataPath(jsonLoaderPath, {
|
|
731
|
-
filePath,
|
|
732
|
-
projectRoot,
|
|
733
|
-
}).resolvedPath,
|
|
734
|
-
},
|
|
735
|
-
});
|
|
736
|
-
continue;
|
|
737
|
-
}
|
|
738
|
-
// Check fromJson.map
|
|
739
|
-
const jsonMapPath = jsonMapSources.get(varName);
|
|
740
|
-
if (jsonMapPath) {
|
|
741
|
-
results.push({
|
|
742
|
-
testId,
|
|
743
|
-
line,
|
|
744
|
-
exportName,
|
|
745
|
-
keys: null,
|
|
746
|
-
dataSource: {
|
|
747
|
-
type: "json-map",
|
|
748
|
-
path: resolveDataPath(jsonMapPath, {
|
|
749
|
-
filePath,
|
|
750
|
-
projectRoot,
|
|
751
|
-
}).resolvedPath,
|
|
752
|
-
},
|
|
753
|
-
});
|
|
754
|
-
continue;
|
|
755
|
-
}
|
|
756
|
-
// Unknown variable
|
|
757
|
-
results.push({
|
|
758
|
-
testId,
|
|
759
|
-
line,
|
|
760
|
-
exportName,
|
|
761
|
-
keys: null,
|
|
762
|
-
dataSource: undefined,
|
|
763
|
-
});
|
|
764
|
-
}
|
|
765
|
-
return results;
|
|
766
|
-
}
|
|
767
|
-
/**
|
|
768
|
-
* Extract contract.http()/contract.grpc()/etc. metadata from TypeScript source.
|
|
769
|
-
*
|
|
770
|
-
* Statically extracts:
|
|
771
|
-
* - Contract ID, endpoint, export name, line number
|
|
772
|
-
* - Each case key with its line number
|
|
773
|
-
* - Expected status code (if literal number)
|
|
774
|
-
* - Deferred reason (if literal string)
|
|
775
|
-
*
|
|
776
|
-
* @param content - TypeScript source code
|
|
777
|
-
* @returns Array of ContractStaticMeta, or empty if no contract calls found
|
|
778
|
-
*/
|
|
779
|
-
export function extractContractCases(content) {
|
|
780
|
-
const results = [];
|
|
781
|
-
// Match: export const X = contract.http("id", {
|
|
782
|
-
const contractPattern = /export\s+const\s+(\w+)\s*=\s*contract\.(\w+)\s*\(\s*["']([^"']+)["']\s*,\s*\{/g;
|
|
783
|
-
let contractMatch;
|
|
784
|
-
while ((contractMatch = contractPattern.exec(content)) !== null) {
|
|
785
|
-
const exportName = contractMatch[1];
|
|
786
|
-
const protocol = contractMatch[2];
|
|
787
|
-
const contractId = contractMatch[3];
|
|
788
|
-
const line = getLineNumber(content, contractMatch.index);
|
|
789
|
-
const afterContract = content.slice(contractMatch.index + contractMatch[0].length);
|
|
790
|
-
// Find the cases: { ... } block
|
|
791
|
-
const casesStart = afterContract.indexOf("cases:");
|
|
792
|
-
// Extract contract-level fields from the region BEFORE cases: to avoid
|
|
793
|
-
// matching case-level fields inside the cases block.
|
|
794
|
-
const specHeader = casesStart !== -1 ? afterContract.slice(0, casesStart) : afterContract;
|
|
795
|
-
let endpoint = "";
|
|
796
|
-
const endpointMatch = specHeader.match(/endpoint\s*:\s*["']([^"']+)["']/);
|
|
797
|
-
if (endpointMatch) {
|
|
798
|
-
endpoint = endpointMatch[1];
|
|
799
|
-
}
|
|
800
|
-
let description;
|
|
801
|
-
const descriptionMatch = specHeader.match(/description\s*:\s*["']([^"']+)["']/);
|
|
802
|
-
if (descriptionMatch) {
|
|
803
|
-
description = descriptionMatch[1];
|
|
804
|
-
}
|
|
805
|
-
let feature;
|
|
806
|
-
const featureMatch = specHeader.match(/feature\s*:\s*["']([^"']+)["']/);
|
|
807
|
-
if (featureMatch) {
|
|
808
|
-
feature = featureMatch[1];
|
|
809
|
-
}
|
|
810
|
-
if (casesStart === -1) {
|
|
811
|
-
results.push({ contractId, exportName, line, endpoint, protocol, description, feature, cases: [] });
|
|
812
|
-
continue;
|
|
813
|
-
}
|
|
814
|
-
// Find the opening brace after "cases:"
|
|
815
|
-
const afterCases = afterContract.slice(casesStart);
|
|
816
|
-
const braceIdx = afterCases.indexOf("{");
|
|
817
|
-
if (braceIdx === -1) {
|
|
818
|
-
results.push({ contractId, exportName, line, endpoint, protocol, description, feature, cases: [] });
|
|
819
|
-
continue;
|
|
820
|
-
}
|
|
821
|
-
// Extract top-level keys in the cases object by tracking brace depth
|
|
822
|
-
const casesContent = afterCases.slice(braceIdx);
|
|
823
|
-
const cases = [];
|
|
824
|
-
const topLevelKeys = [];
|
|
825
|
-
let depth = 0;
|
|
826
|
-
for (let i = 0; i < casesContent.length; i++) {
|
|
827
|
-
if (casesContent[i] === "{") {
|
|
828
|
-
if (depth === 1) {
|
|
829
|
-
// Look backward for the key name
|
|
830
|
-
const before = casesContent.slice(0, i).trimEnd();
|
|
831
|
-
const keyMatch = before.match(/["']?(\w+)["']?\s*:\s*$/);
|
|
832
|
-
if (keyMatch) {
|
|
833
|
-
topLevelKeys.push({ key: keyMatch[1], offset: i });
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
depth++;
|
|
837
|
-
}
|
|
838
|
-
else if (casesContent[i] === "}") {
|
|
839
|
-
depth--;
|
|
840
|
-
if (depth === 0)
|
|
841
|
-
break;
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
for (const { key, offset } of topLevelKeys) {
|
|
845
|
-
const absoluteOffset = contractMatch.index +
|
|
846
|
-
contractMatch[0].length +
|
|
847
|
-
casesStart +
|
|
848
|
-
braceIdx +
|
|
849
|
-
offset;
|
|
850
|
-
const caseLine = getLineNumber(content, absoluteOffset);
|
|
851
|
-
// Extract case body between matching braces
|
|
852
|
-
let caseDepth = 0;
|
|
853
|
-
let caseEnd = offset;
|
|
854
|
-
for (let i = offset; i < casesContent.length; i++) {
|
|
855
|
-
if (casesContent[i] === "{")
|
|
856
|
-
caseDepth++;
|
|
857
|
-
else if (casesContent[i] === "}") {
|
|
858
|
-
caseDepth--;
|
|
859
|
-
if (caseDepth === 0) {
|
|
860
|
-
caseEnd = i;
|
|
861
|
-
break;
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
const caseBody = casesContent.slice(offset, caseEnd + 1);
|
|
866
|
-
let description;
|
|
867
|
-
const descMatch = caseBody.match(/description\s*:\s*["']([^"']+)["']/);
|
|
868
|
-
if (descMatch)
|
|
869
|
-
description = descMatch[1];
|
|
870
|
-
let expectStatus;
|
|
871
|
-
const statusMatch = caseBody.match(/status\s*:\s*(\d+)/);
|
|
872
|
-
if (statusMatch)
|
|
873
|
-
expectStatus = parseInt(statusMatch[1], 10);
|
|
874
|
-
let deferred;
|
|
875
|
-
const deferredMatch = caseBody.match(/deferred\s*:\s*["']([^"']+)["']/);
|
|
876
|
-
if (deferredMatch)
|
|
877
|
-
deferred = deferredMatch[1];
|
|
878
|
-
let requires;
|
|
879
|
-
const requiresMatch = caseBody.match(/requires\s*:\s*["'](headless|browser|out-of-band)["']/);
|
|
880
|
-
if (requiresMatch)
|
|
881
|
-
requires = requiresMatch[1];
|
|
882
|
-
let defaultRun;
|
|
883
|
-
const defaultRunMatch = caseBody.match(/defaultRun\s*:\s*["'](always|opt-in)["']/);
|
|
884
|
-
if (defaultRunMatch)
|
|
885
|
-
defaultRun = defaultRunMatch[1];
|
|
886
|
-
let given;
|
|
887
|
-
const givenMatch = caseBody.match(/given\s*:\s*["']([^"']+)["']/);
|
|
888
|
-
if (givenMatch)
|
|
889
|
-
given = givenMatch[1];
|
|
890
|
-
cases.push({
|
|
891
|
-
key,
|
|
892
|
-
line: caseLine,
|
|
893
|
-
description,
|
|
894
|
-
expectStatus,
|
|
895
|
-
deferred,
|
|
896
|
-
requires,
|
|
897
|
-
defaultRun,
|
|
898
|
-
given,
|
|
899
|
-
});
|
|
900
|
-
}
|
|
901
|
-
results.push({ contractId, exportName, line, endpoint, protocol, description, feature, cases });
|
|
902
|
-
}
|
|
903
|
-
return results;
|
|
904
|
-
}
|
|
905
163
|
//# sourceMappingURL=extractor-static.js.map
|