bmad-method-test-architecture-enterprise 3.1.0 → 3.2.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/.claude-plugin/marketplace.json +1 -1
- package/cli/automate.js +7 -0
- package/cli/examples/gitlab-ci.yaml +1 -1
- package/cli/examples/pr-test-review.yml +3 -3
- package/cli/lib/automate-inventory.js +258 -25
- package/cli/lib/automate-prompt.js +34 -2
- package/cli/lib/automate-result.js +74 -2
- package/cli/lib/unit-runners.js +219 -0
- package/cli/lib/vitest-attempts-reporter.mjs +30 -0
- package/cli/unit-check.js +595 -0
- package/package.json +12 -3
- package/skills/bmad-tea-automate/red/checklist.md +1 -1
- package/skills/bmad-tea-automate/resources/run-and-heal.md +3 -1
- package/skills/bmad-tea-evaluate/references/evaluator.md +2 -2
- package/skills/bmad-tea-evaluate/references/gaps.md +1 -1
- package/skills/bmad-tea-test-review/steps-c/step-03f-aggregate-scores.md +3 -3
- package/skills/bmod-tea/bmod.toml +1 -1
- package/skills/bmod-tea/knowledge/auth-session.md +25 -20
- package/skills/bmod-tea/knowledge/test-healing-patterns.md +2 -0
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"name": "bmad-method-test-architecture-enterprise",
|
|
32
32
|
"source": "./",
|
|
33
33
|
"description": "Master Test Architect module for quality strategy, test generation in red and expand modes with automatic run-and-heal, combined framework and CI setup, CI/CD quality gates, and structured testing education. Existing CI and ATDD customizations can be migrated to the new skill names. Part of the BMad Method ecosystem.",
|
|
34
|
-
"version": "3.
|
|
34
|
+
"version": "3.2.0",
|
|
35
35
|
"author": {
|
|
36
36
|
"name": "Murat K Ozcan (TEA Creator) & Brian (BMad) Madison"
|
|
37
37
|
},
|
package/cli/automate.js
CHANGED
|
@@ -8,6 +8,7 @@ const { Command } = require('commander');
|
|
|
8
8
|
const { addAgentOptions, agentOptions, projectPath, resolveWorkflowSkill, headlessPrompt, runWithEvidence } = require('./lib/workflow-cli');
|
|
9
9
|
const { artifactPath, parseResult, frontmatter } = require('./lib/automate-result');
|
|
10
10
|
const { resolveGenerationConfig, generationRequest } = require('./lib/automate-prompt');
|
|
11
|
+
const { detectRunners } = require('./lib/unit-runners');
|
|
11
12
|
|
|
12
13
|
const NAME = 'tea-automate';
|
|
13
14
|
const MODES = ['red', 'expand'];
|
|
@@ -120,6 +121,7 @@ async function main(argv) {
|
|
|
120
121
|
let modeSelection;
|
|
121
122
|
let checkpointBefore;
|
|
122
123
|
let protectedFiles;
|
|
124
|
+
let runners;
|
|
123
125
|
try {
|
|
124
126
|
if (options.mode !== undefined && !MODES.includes(options.mode)) throw new Error(`--mode must be ${MODES.join(' or ')}`);
|
|
125
127
|
if (!OPERATIONS.includes(options.operation)) throw new Error(`--operation must be ${OPERATIONS.join(', ')}`);
|
|
@@ -173,6 +175,8 @@ async function main(argv) {
|
|
|
173
175
|
if (!request.trim() && !story && targets.length === 0 && !checkpoint)
|
|
174
176
|
throw new Error('supply a request, --story, --target or --checkpoint');
|
|
175
177
|
if (options.operation === 'edit' && !request.trim()) throw new Error('Edit requires instructions in the request or standard input');
|
|
178
|
+
// Detected before the agent runs, so generated edits cannot change which runner evidence is accepted.
|
|
179
|
+
runners = detectRunners(root);
|
|
176
180
|
resolvedConfig = resolveGenerationConfig(
|
|
177
181
|
root,
|
|
178
182
|
checkpoint ? path.join(root, checkpoint) : undefined,
|
|
@@ -205,6 +209,8 @@ async function main(argv) {
|
|
|
205
209
|
requestId,
|
|
206
210
|
settings: resolvedConfig.settings,
|
|
207
211
|
modeSelection,
|
|
212
|
+
runners,
|
|
213
|
+
projectRoot: root,
|
|
208
214
|
}),
|
|
209
215
|
});
|
|
210
216
|
return { prompt, manifestPath, requestId, startedAtMs: Date.now() };
|
|
@@ -236,6 +242,7 @@ async function main(argv) {
|
|
|
236
242
|
selectedCheckpoint: checkpoint,
|
|
237
243
|
checkpointBefore,
|
|
238
244
|
evidenceDir: attemptDir,
|
|
245
|
+
runners,
|
|
239
246
|
}),
|
|
240
247
|
});
|
|
241
248
|
const payload = { ...result.value, modeSelection, evidenceDir: result.runDir, agent: options.agent };
|
|
@@ -19,7 +19,7 @@ tea-test-review:
|
|
|
19
19
|
variables:
|
|
20
20
|
GIT_DEPTH: "0" # the review diffs changed test files against the target branch
|
|
21
21
|
script:
|
|
22
|
-
- npm install --global bmad-method-test-architecture-enterprise@1.
|
|
22
|
+
- npm install --global bmad-method-test-architecture-enterprise@3.1.0 @anthropic-ai/claude-code@2.1.220
|
|
23
23
|
- git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
|
24
24
|
- |
|
|
25
25
|
status=0
|
|
@@ -61,10 +61,10 @@ jobs:
|
|
|
61
61
|
checks: write
|
|
62
62
|
env:
|
|
63
63
|
# Pin both, so the reviewer's control plane is the exact code you vetted
|
|
64
|
-
# and not whatever `latest` resolves to when a pull request lands.
|
|
64
|
+
# and not whatever `latest` resolves to when a pull request lands. 2.0.0
|
|
65
65
|
# is the first release that ships --github and the packaged skill: use
|
|
66
|
-
# the exact version you vetted.
|
|
67
|
-
TEA_VERSION: 1.
|
|
66
|
+
# the exact published version you vetted.
|
|
67
|
+
TEA_VERSION: 3.1.0
|
|
68
68
|
CLAUDE_CODE_VERSION: 2.1.220
|
|
69
69
|
steps:
|
|
70
70
|
- name: Checkout (full history for the PR diff)
|
|
@@ -5,6 +5,7 @@ const path = require('node:path');
|
|
|
5
5
|
const { createHash } = require('node:crypto');
|
|
6
6
|
const { parse } = require('@babel/parser');
|
|
7
7
|
const { minimatch } = require('minimatch');
|
|
8
|
+
const { UNIT_CHECK_KIND, normalizeUnitReport, unitReportRunner } = require('./unit-runners');
|
|
8
9
|
|
|
9
10
|
const slash = (value) => value.split(path.sep).join('/');
|
|
10
11
|
const canonical = (value) => {
|
|
@@ -48,8 +49,31 @@ function nativeMatches(patterns, absolute) {
|
|
|
48
49
|
});
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
// Registration names per runner family. Playwright registers suites through
|
|
53
|
+
// test.describe and maps a transformed named import's call to its callee end.
|
|
54
|
+
// Vitest and Jest export describe/suite as their own functions, and both report
|
|
55
|
+
// an identifier call at its start and a member call at its property.
|
|
56
|
+
// Vitest maps an identifier call to its callee start or, when its transform
|
|
57
|
+
// leaves the call to Vite's own SSR rewrite (JavaScript in Vitest 5, or
|
|
58
|
+
// esbuild: false), to its opening parenthesis. A transformed Jest ES import maps
|
|
59
|
+
// it to the callee end. Both positions belong to one call, so each still binds
|
|
60
|
+
// exactly one leaf; Jest globals and requires keep a single location.
|
|
61
|
+
const VOCABULARIES = {
|
|
62
|
+
playwright: { names: { test: 'test', mergeTests: 'merge' }, globals: { test: 'test' }, importEnd: true, alternate: null },
|
|
63
|
+
unit: {
|
|
64
|
+
names: { test: 'test', it: 'test', describe: 'suite', suite: 'suite', vi: 'utility', jest: 'utility' },
|
|
65
|
+
globals: { test: 'test', it: 'test', describe: 'suite', suite: 'suite', vi: 'utility', jest: 'utility' },
|
|
66
|
+
importEnd: false,
|
|
67
|
+
alternate: null,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
// Modules whose exports register tests; a call rooted in any other installed package registers none.
|
|
71
|
+
const TEST_MODULES = new Set(['vitest', '@jest/globals', '@playwright/test']);
|
|
72
|
+
const REGISTRATION_KINDS = new Set(['test', 'suite', 'namespace', 'merge']);
|
|
73
|
+
|
|
51
74
|
// Track lexical declarations so a shadowed name cannot inherit a test binding.
|
|
52
|
-
function sourceBindings(ast, file, projectRoot) {
|
|
75
|
+
function sourceBindings(ast, file, projectRoot, vocabulary = VOCABULARIES.playwright, source = '') {
|
|
76
|
+
const kindOf = (name) => (typeof name === 'string' && Object.hasOwn(vocabulary.names, name) ? vocabulary.names[name] : 'other');
|
|
53
77
|
const scopes = new WeakMap();
|
|
54
78
|
const root = { parent: null, bindings: new Map(), functionScope: true };
|
|
55
79
|
const assignments = [];
|
|
@@ -100,10 +124,11 @@ function sourceBindings(ast, file, projectRoot) {
|
|
|
100
124
|
scopes.set(node, scope);
|
|
101
125
|
if (node.type === 'ImportDeclaration')
|
|
102
126
|
for (const item of node.specifiers) {
|
|
103
|
-
const
|
|
127
|
+
const namespace = ['ImportNamespaceSpecifier', 'ImportDefaultSpecifier'].includes(item.type);
|
|
104
128
|
scope.bindings.set(item.local.name, {
|
|
105
|
-
kind:
|
|
129
|
+
kind: namespace ? 'namespace' : kindOf(item.imported?.name ?? literal(item.imported)),
|
|
106
130
|
imported: true,
|
|
131
|
+
source: node.source.value,
|
|
107
132
|
});
|
|
108
133
|
}
|
|
109
134
|
if (node.type === 'VariableDeclarator') {
|
|
@@ -143,31 +168,31 @@ function sourceBindings(ast, file, projectRoot) {
|
|
|
143
168
|
if (!node) return null;
|
|
144
169
|
if (node.type === 'Identifier') {
|
|
145
170
|
const binding = findBinding(node);
|
|
146
|
-
// Bare
|
|
147
|
-
if (!binding)
|
|
171
|
+
// Bare globals are retained for native ATDD and Jest/Vitest global inventories.
|
|
172
|
+
if (!binding)
|
|
173
|
+
return Object.hasOwn(vocabulary.globals, node.name)
|
|
174
|
+
? { kind: vocabulary.globals[node.name], importEnd: vocabulary.importEnd }
|
|
175
|
+
: null;
|
|
148
176
|
if (seen.has(binding)) return null;
|
|
149
177
|
seen = new Set([...seen, binding]);
|
|
150
178
|
if (
|
|
151
179
|
binding.assignments?.length &&
|
|
152
|
-
(
|
|
153
|
-
|
|
154
|
-
binding.assignments.some((value) =>
|
|
180
|
+
(REGISTRATION_KINDS.has(binding.kind) ||
|
|
181
|
+
REGISTRATION_KINDS.has(resolve(binding.init, seen)?.kind) ||
|
|
182
|
+
binding.assignments.some((value) => REGISTRATION_KINDS.has(resolve(value, seen)?.kind)))
|
|
155
183
|
)
|
|
156
184
|
throw new Error(`generated test inventory has reassigned test binding: ${file}`);
|
|
157
|
-
if (binding.kind)
|
|
185
|
+
if (binding.kind)
|
|
186
|
+
return { kind: binding.kind, importEnd: vocabulary.importEnd && binding.imported && !moduleSource, imported: binding.imported };
|
|
158
187
|
const value = resolve(binding.init, seen);
|
|
159
|
-
if (binding.property)
|
|
160
|
-
return value?.kind === 'namespace'
|
|
161
|
-
? { kind: binding.property === 'test' ? 'test' : binding.property === 'mergeTests' ? 'merge' : 'other' }
|
|
162
|
-
: null;
|
|
188
|
+
if (binding.property) return value?.kind === 'namespace' ? { kind: kindOf(binding.property) } : null;
|
|
163
189
|
return value ? { kind: value.kind } : null;
|
|
164
190
|
}
|
|
165
191
|
if (node.type === 'MemberExpression') {
|
|
166
192
|
const value = resolve(node.object, seen);
|
|
167
193
|
if (value?.kind === 'namespace' && node.computed && memberName(node) === null)
|
|
168
194
|
throw new Error(`generated test inventory has computed registration: ${file}`);
|
|
169
|
-
if (value?.kind === 'namespace')
|
|
170
|
-
return { kind: memberName(node) === 'test' ? 'test' : memberName(node) === 'mergeTests' ? 'merge' : 'other' };
|
|
195
|
+
if (value?.kind === 'namespace') return { kind: kindOf(memberName(node)) };
|
|
171
196
|
return null;
|
|
172
197
|
}
|
|
173
198
|
if (node.type === 'CallExpression') {
|
|
@@ -189,7 +214,7 @@ function sourceBindings(ast, file, projectRoot) {
|
|
|
189
214
|
};
|
|
190
215
|
const registration = (node) => {
|
|
191
216
|
const value = resolve(node);
|
|
192
|
-
if (value?.kind === 'test') return { binding: value, chain: [], base: node };
|
|
217
|
+
if (value?.kind === 'test' || value?.kind === 'suite') return { kind: value.kind, binding: value, chain: [], base: node };
|
|
193
218
|
if (node?.type === 'MemberExpression') {
|
|
194
219
|
const parent = registration(node.object);
|
|
195
220
|
if (parent) return { ...parent, chain: [...parent.chain, memberName(node)] };
|
|
@@ -202,16 +227,152 @@ function sourceBindings(ast, file, projectRoot) {
|
|
|
202
227
|
const point = callee.type === 'MemberExpression' ? callee.property.loc.start : binding.importEnd ? callee.loc.end : callee.loc.start;
|
|
203
228
|
return { line: point.line, column: point.column + 1 };
|
|
204
229
|
};
|
|
205
|
-
|
|
230
|
+
// The second location an identifier call can map to: the opening parenthesis
|
|
231
|
+
// (Vitest 5 JavaScript), or the callee end of an ES import a Jest transform
|
|
232
|
+
// compiles to `(0, _globals.test)(...)` (babel-jest, ts-jest).
|
|
233
|
+
const lineStarts = [0];
|
|
234
|
+
for (let index = 0; index < source.length; index++) if (source[index] === '\n') lineStarts.push(index + 1);
|
|
235
|
+
const alternate = (call, callee, binding) => {
|
|
236
|
+
if (callee.type !== 'Identifier') return null;
|
|
237
|
+
if (vocabulary.alternate === 'import-end')
|
|
238
|
+
return binding?.imported ? { line: callee.loc.end.line, column: callee.loc.end.column + 1 } : null;
|
|
239
|
+
if (vocabulary.alternate !== 'paren') return null;
|
|
240
|
+
let offset = callee.end;
|
|
241
|
+
while (offset < call.end && source[offset] !== '(') offset++;
|
|
242
|
+
let line = lineStarts.length - 1;
|
|
243
|
+
while (lineStarts[line] > offset) line--;
|
|
244
|
+
return { line: line + 1, column: offset - lineStarts[line] + 1 };
|
|
245
|
+
};
|
|
246
|
+
// A member call rooted at a runner utility (vi.mock, an aliased or namespaced vi) registers no test.
|
|
247
|
+
const utility = (callee) => {
|
|
248
|
+
for (let node = callee.type === 'MemberExpression' ? callee.object : null; node; node = node.object) {
|
|
249
|
+
if (resolve(node)?.kind === 'utility') return true;
|
|
250
|
+
if (node.type !== 'MemberExpression') return false;
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
};
|
|
254
|
+
// Nor does a member call rooted in another installed package, such as MSW's
|
|
255
|
+
// http.get(path, resolver) or an Express app built by express().
|
|
256
|
+
const packageSource = (source) =>
|
|
257
|
+
typeof source === 'string' && !/^[./]/.test(source) && !TEST_MODULES.has(source) && !source.startsWith('vitest/');
|
|
258
|
+
const external = (callee) => {
|
|
259
|
+
const seen = new Set();
|
|
260
|
+
let node = callee.type === 'MemberExpression' ? callee.object : null;
|
|
261
|
+
while (node) {
|
|
262
|
+
switch (node.type) {
|
|
263
|
+
case 'MemberExpression': {
|
|
264
|
+
node = node.object;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case 'CallExpression': {
|
|
268
|
+
if (node.callee.type === 'Identifier' && node.callee.name === 'require' && !findBinding(node.callee))
|
|
269
|
+
return packageSource(literal(node.arguments[0]));
|
|
270
|
+
node = node.callee;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
case 'Identifier': {
|
|
274
|
+
const binding = findBinding(node);
|
|
275
|
+
if (!binding || seen.has(binding)) return false;
|
|
276
|
+
seen.add(binding);
|
|
277
|
+
if (binding.imported) return packageSource(binding.source);
|
|
278
|
+
node = binding.init;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
default: {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
};
|
|
288
|
+
return { registration, location, alternate, utility, external };
|
|
206
289
|
}
|
|
207
290
|
|
|
208
291
|
// Enumerate registrations without executing adopter code. Runtime enumeration is
|
|
209
292
|
// deliberately unmeasured: a numerical report cannot prove its missing leaves.
|
|
210
|
-
|
|
293
|
+
// Vitest and Jest share one registration grammar: test/it leaves, describe/suite
|
|
294
|
+
// blocks and module-level hooks. Every form whose leaves are only known at
|
|
295
|
+
// runtime is rejected, matching the Playwright inventory's strictness.
|
|
296
|
+
const UNIT_LEAF_MODIFIERS = new Set(['only', 'skip', 'concurrent', 'sequential']);
|
|
297
|
+
const UNIT_SUITE_MODIFIERS = new Set(['only', 'skip', 'concurrent', 'sequential', 'shuffle']);
|
|
298
|
+
const UNIT_DYNAMIC = new Set(['each', 'for', 'skipIf', 'runIf', 'todo', 'fails', 'failing']);
|
|
299
|
+
|
|
300
|
+
function unitLeaves(ast, file, bindings, inventory, activations) {
|
|
301
|
+
const { registration, location, alternate, utility, external } = bindings;
|
|
302
|
+
const isCallback = (arg) => ['ArrowFunctionExpression', 'FunctionExpression'].includes(arg.type);
|
|
303
|
+
const walk = (node, titles = [], dynamic = false) => {
|
|
304
|
+
if (!node || typeof node !== 'object') return;
|
|
305
|
+
if (node.type === 'CallExpression') {
|
|
306
|
+
const resolved = registration(node.callee);
|
|
307
|
+
// test.each(table)('title', fn) and its relatives register through a returned function.
|
|
308
|
+
if (!resolved && node.callee.type === 'CallExpression' && registration(node.callee.callee))
|
|
309
|
+
throw new Error(`generated test inventory has unresolved table registration: ${file}`);
|
|
310
|
+
if (
|
|
311
|
+
!resolved &&
|
|
312
|
+
!dynamic &&
|
|
313
|
+
!utility(node.callee) &&
|
|
314
|
+
!external(node.callee) &&
|
|
315
|
+
literal(node.arguments[0]) !== null &&
|
|
316
|
+
node.arguments.some(isCallback)
|
|
317
|
+
)
|
|
318
|
+
throw new Error(`generated test inventory has unresolved registration binding: ${file}`);
|
|
319
|
+
if (resolved) {
|
|
320
|
+
const { chain } = resolved;
|
|
321
|
+
if (chain.includes(null)) throw new Error(`generated test inventory has computed registration: ${file}`);
|
|
322
|
+
if (chain.some((member) => UNIT_DYNAMIC.has(member)))
|
|
323
|
+
throw new Error(`generated test inventory has unresolved table registration: ${file}`);
|
|
324
|
+
const callback = node.arguments.find(isCallback);
|
|
325
|
+
if (resolved.kind === 'suite') {
|
|
326
|
+
if (chain.length > 1 || (chain.length === 1 && !UNIT_SUITE_MODIFIERS.has(chain[0])))
|
|
327
|
+
throw new Error(`generated test inventory has unsupported registration: ${file}`);
|
|
328
|
+
const title = literal(node.arguments[0]);
|
|
329
|
+
if (title === null || !callback || dynamic) throw new Error(`generated test inventory has dynamic suite registration: ${file}`);
|
|
330
|
+
walk(callback.body, [...titles, title], false);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (chain.length === 0 || (chain.length === 1 && UNIT_LEAF_MODIFIERS.has(chain[0]))) {
|
|
334
|
+
const title = literal(node.arguments[0]);
|
|
335
|
+
if (!callback) throw new Error(`generated test inventory has unresolved callback registration: ${file}`);
|
|
336
|
+
if (title === null || dynamic) throw new Error(`generated test inventory has dynamic test registration: ${file}`);
|
|
337
|
+
const skip = chain[0] === 'skip';
|
|
338
|
+
const base = skip ? location(resolved.base, resolved.binding) : null;
|
|
339
|
+
// Activation blanks `.skip`, so the activated call keeps this call's parenthesis.
|
|
340
|
+
const paren = alternate(node, node.callee, resolved.binding);
|
|
341
|
+
const activatedParen = skip ? alternate(node, resolved.base, resolved.binding) : null;
|
|
342
|
+
inventory.tests.push({
|
|
343
|
+
file,
|
|
344
|
+
titlePath: [...titles, title],
|
|
345
|
+
...location(node.callee, resolved.binding),
|
|
346
|
+
...(paren ? { alternateLine: paren.line, alternateColumn: paren.column } : {}),
|
|
347
|
+
...(skip ? { activationLine: base.line, activationColumn: base.column } : {}),
|
|
348
|
+
...(activatedParen ? { activationAlternateLine: activatedParen.line, activationAlternateColumn: activatedParen.column } : {}),
|
|
349
|
+
});
|
|
350
|
+
// Activation deletes exactly the `.skip` member between the base and the call.
|
|
351
|
+
if (skip && activations) activations.push({ file, start: node.callee.object.end, end: node.callee.end });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (!(chain.length === 1 && chain[0] === 'extend'))
|
|
355
|
+
throw new Error(`generated test inventory has unsupported registration: ${file}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const nestedDynamic = dynamic || /(?:Function|Method|For|While|If|Conditional|Switch)/.test(node.type);
|
|
359
|
+
for (const [key, value] of Object.entries(node)) {
|
|
360
|
+
if (['loc', 'start', 'end'].includes(key)) continue;
|
|
361
|
+
if (Array.isArray(value)) for (const child of value) walk(child, titles, nestedDynamic);
|
|
362
|
+
else if (value && typeof value === 'object') walk(value, titles, nestedDynamic);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
walk(ast.program);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function generatedInventory(projectRoot, files, { runner = 'playwright', activations } = {}) {
|
|
369
|
+
const unit = runner === 'vitest' || runner === 'jest';
|
|
370
|
+
if (!unit && runner !== 'playwright') throw new Error(`generated test inventory has an unsupported runner: ${runner}`);
|
|
211
371
|
const inventory = {
|
|
212
372
|
schemaVersion: 1,
|
|
213
373
|
basis: 'post-generation source syntax',
|
|
214
374
|
projectRoot: fs.realpathSync(projectRoot),
|
|
375
|
+
...(unit ? { runner } : {}),
|
|
215
376
|
files: [],
|
|
216
377
|
tests: [],
|
|
217
378
|
};
|
|
@@ -220,6 +381,12 @@ function generatedInventory(projectRoot, files) {
|
|
|
220
381
|
inventory.files.push({ file, sha256: createHash('sha256').update(bytes).digest('hex') });
|
|
221
382
|
if (!/\.(?:[cm]?[jt]sx?)$/.test(file)) continue;
|
|
222
383
|
const ast = parse(bytes.toString('utf8'), { sourceType: 'unambiguous', plugins: ['typescript', 'jsx'] });
|
|
384
|
+
if (unit) {
|
|
385
|
+
const alternate = runner === 'jest' ? 'import-end' : 'paren';
|
|
386
|
+
const vocabulary = { ...VOCABULARIES.unit, alternate };
|
|
387
|
+
unitLeaves(ast, file, sourceBindings(ast, file, projectRoot, vocabulary, bytes.toString('utf8')), inventory, activations);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
223
390
|
const { registration, location } = sourceBindings(ast, file, projectRoot);
|
|
224
391
|
const walk = (node, titles = [], dynamic = false) => {
|
|
225
392
|
if (!node || typeof node !== 'object') return;
|
|
@@ -307,20 +474,29 @@ function generatedInventory(projectRoot, files) {
|
|
|
307
474
|
return inventory;
|
|
308
475
|
}
|
|
309
476
|
|
|
310
|
-
function
|
|
477
|
+
function reportFamily(report) {
|
|
478
|
+
if (Array.isArray(report.suites)) return 'playwright';
|
|
479
|
+
if (report.kind === UNIT_CHECK_KIND) return `unit-check:${report.runner}`;
|
|
480
|
+
return unitReportRunner(report) ?? (report.schemaVersion === 1 && Array.isArray(report.files) ? 'native-atdd' : 'unrecognized');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function reconcileInventory(inventory, reports, { mode, runners, reportProjectRoot = inventory.projectRoot } = {}) {
|
|
311
484
|
const covered = new Map();
|
|
312
485
|
const projectsByFile = new Map();
|
|
313
|
-
if (reports.
|
|
314
|
-
throw new Error('final execution mixes incompatible runner scope identities');
|
|
486
|
+
if (new Set(reports.map(reportFamily)).size > 1) throw new Error('final execution mixes incompatible runner scope identities');
|
|
315
487
|
const identity = (leaf, project) => JSON.stringify([leaf.file, leaf.titlePath, leaf.line, leaf.column, project]);
|
|
316
|
-
const
|
|
488
|
+
const sourceLeaves = new Map();
|
|
317
489
|
const atddLeaves = new Map();
|
|
318
490
|
const sourceKey = (file, titles, line, column) => JSON.stringify([file, titles, line, column]);
|
|
319
491
|
const index = (map, key, leaf) => map.set(key, [...(map.get(key) ?? []), leaf]);
|
|
320
492
|
for (const leaf of inventory.tests) {
|
|
321
|
-
index(
|
|
493
|
+
index(sourceLeaves, sourceKey(leaf.file, leaf.titlePath, leaf.line, leaf.column), leaf);
|
|
494
|
+
if (leaf.alternateLine !== undefined)
|
|
495
|
+
index(sourceLeaves, sourceKey(leaf.file, leaf.titlePath, leaf.alternateLine, leaf.alternateColumn), leaf);
|
|
322
496
|
if (mode === 'red' && leaf.activationLine !== undefined)
|
|
323
|
-
index(
|
|
497
|
+
index(sourceLeaves, sourceKey(leaf.file, leaf.titlePath, leaf.activationLine, leaf.activationColumn), leaf);
|
|
498
|
+
if (mode === 'red' && leaf.activationAlternateLine !== undefined)
|
|
499
|
+
index(sourceLeaves, sourceKey(leaf.file, leaf.titlePath, leaf.activationAlternateLine, leaf.activationAlternateColumn), leaf);
|
|
324
500
|
index(atddLeaves, JSON.stringify([leaf.file, leaf.titlePath.at(-1)]), leaf);
|
|
325
501
|
}
|
|
326
502
|
for (const report of reports) {
|
|
@@ -368,7 +544,7 @@ function reconcileInventory(inventory, reports, { mode, reportProjectRoot = inve
|
|
|
368
544
|
for (const spec of suite.specs ?? []) {
|
|
369
545
|
const file = reportedFile(spec.file ?? suite.file ?? '');
|
|
370
546
|
const titlePath = [...nextTitles, spec.title];
|
|
371
|
-
const leaves =
|
|
547
|
+
const leaves = sourceLeaves.get(sourceKey(file, titlePath, spec.line, spec.column)) ?? [];
|
|
372
548
|
if (leaves.length !== 1) throw new Error(`runner result does not identify one generated test leaf: ${file} ${spec.title ?? ''}`);
|
|
373
549
|
for (const test of spec.tests ?? []) {
|
|
374
550
|
const project = test.projectId ?? test.projectName;
|
|
@@ -386,6 +562,63 @@ function reconcileInventory(inventory, reports, { mode, reportProjectRoot = inve
|
|
|
386
562
|
if (covered.has(key)) throw new Error('final runner reports repeat a generated test/project scope');
|
|
387
563
|
covered.set(key, count);
|
|
388
564
|
}
|
|
565
|
+
} else if (report.kind === UNIT_CHECK_KIND || unitReportRunner(report)) {
|
|
566
|
+
const checked = report.kind === UNIT_CHECK_KIND;
|
|
567
|
+
const runner = checked ? report.runner : unitReportRunner(report);
|
|
568
|
+
const label = runner === 'jest' ? 'Jest' : runner === 'vitest' ? 'Vitest' : JSON.stringify(runner);
|
|
569
|
+
const declared = (runners ?? []).find((entry) => entry.runner === runner);
|
|
570
|
+
if (!declared) throw new Error(`native ${label} evidence does not match a test runner the project declares`);
|
|
571
|
+
if (inventory.runner !== runner) throw new Error(`generated test inventory was not derived for ${label}`);
|
|
572
|
+
if (!reportProjectRoot) throw new Error('native execution reconciliation requires the consuming project root');
|
|
573
|
+
const base = canonical(reportProjectRoot);
|
|
574
|
+
// Vitest's JSON reporter drops unhandled errors and the process outcome, so
|
|
575
|
+
// only tea-unit-check (which records both) establishes Vitest execution.
|
|
576
|
+
// Red needs the disposable activation only tea-unit-check proves.
|
|
577
|
+
if (!checked && (runner === 'vitest' || mode === 'red'))
|
|
578
|
+
throw new Error(
|
|
579
|
+
`raw ${label} JSON cannot establish ${mode === 'red' ? 'verified red' : 'Vitest execution'}; run tea-unit-check --mode ${mode === 'red' ? 'red' : 'expand'}`,
|
|
580
|
+
);
|
|
581
|
+
let results;
|
|
582
|
+
const red = checked && report.mode === 'red';
|
|
583
|
+
if (checked) {
|
|
584
|
+
if (report.schemaVersion !== 1 || typeof report.projectRoot !== 'string' || canonical(report.projectRoot) !== base)
|
|
585
|
+
throw new Error('tea-unit-check evidence identifies a different consuming project root');
|
|
586
|
+
if (report.mode !== mode)
|
|
587
|
+
throw new Error(`tea-unit-check evidence ran in ${JSON.stringify(report.mode)} mode for a ${mode} request`);
|
|
588
|
+
if (report.configFile !== declared.configFile) throw new Error(`tea-unit-check used a different ${label} configuration`);
|
|
589
|
+
if (!Array.isArray(report.files)) throw new Error('tea-unit-check evidence lacks file results');
|
|
590
|
+
results = report.files.map((entry) => {
|
|
591
|
+
const listed = inventory.files.find((item) => item.file === entry?.file);
|
|
592
|
+
if (!listed || listed.sha256 !== entry.sha256)
|
|
593
|
+
throw new Error(`tea-unit-check evaluated different generated bytes: ${entry?.file ?? ''}`);
|
|
594
|
+
if (!Array.isArray(entry.tests)) throw new Error(`tea-unit-check evidence lacks test results: ${entry.file}`);
|
|
595
|
+
return { file: entry.file, tests: entry.tests };
|
|
596
|
+
});
|
|
597
|
+
} else {
|
|
598
|
+
results = normalizeUnitReport(report).files.map((entry) => {
|
|
599
|
+
const relative = path.relative(base, canonical(entry.name));
|
|
600
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
601
|
+
throw new Error(`native ${label} evidence identifies a different consuming project root`);
|
|
602
|
+
return { file: slash(relative), tests: entry.tests };
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
for (const { file, tests } of results) {
|
|
606
|
+
if (inventory.files.some((item) => item.file === file)) projectsByFile.set(file, new Map([[runner, 1]]));
|
|
607
|
+
for (const test of tests) {
|
|
608
|
+
if (!Array.isArray(test?.titlePath) || test.titlePath.some((title) => typeof title !== 'string'))
|
|
609
|
+
throw new Error(`native ${label} result has a malformed test identity: ${file}`);
|
|
610
|
+
if (!Number.isSafeInteger(test.line) || !Number.isSafeInteger(test.column))
|
|
611
|
+
throw new Error(`native ${label} evidence lacks test locations: ${file}`);
|
|
612
|
+
const leaves = sourceLeaves.get(sourceKey(file, test.titlePath, test.line, test.column)) ?? [];
|
|
613
|
+
if (leaves.length !== 1)
|
|
614
|
+
throw new Error(`runner result does not identify one generated test leaf: ${file} ${test.titlePath.at(-1) ?? ''}`);
|
|
615
|
+
if (red && leaves[0].activationLine === undefined)
|
|
616
|
+
throw new Error(`tea-unit-check executed a generated test that is not a skipped scaffold: ${file} ${test.titlePath.at(-1)}`);
|
|
617
|
+
const key = identity(leaves[0], runner);
|
|
618
|
+
if (covered.has(key)) throw new Error('final runner reports repeat a generated test scope');
|
|
619
|
+
covered.set(key, 1);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
389
622
|
} else if (report.schemaVersion === 1 && Array.isArray(report.files)) {
|
|
390
623
|
for (const leaf of inventory.tests) projectsByFile.set(leaf.file, new Map([['native-atdd', 1]]));
|
|
391
624
|
for (const file of report.files)
|
|
@@ -78,7 +78,38 @@ function defaultsAgree(moduleRoot) {
|
|
|
78
78
|
);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
const UNIT_CHECK = path.resolve(__dirname, '..', 'unit-check.js');
|
|
82
|
+
|
|
83
|
+
function runnerLines(mode, runners, projectRoot) {
|
|
84
|
+
const unit = runners.filter(({ runner }) => runner === 'vitest' || runner === 'jest');
|
|
85
|
+
const lines = [
|
|
86
|
+
`Project test runners detected by the CLI from package.json and config files: ${runners.length > 0 ? runners.map(({ runner, configFile }) => `${runner} (${configFile ?? 'declared dependency, default config'})`).join(', ') : 'none'}. Vitest and Jest evidence is accepted only for a detected runner.`,
|
|
87
|
+
];
|
|
88
|
+
if (unit.length === 0) return lines;
|
|
89
|
+
const choose = unit.length > 1 ? ' --runner <vitest or jest, the runner these generated files use>' : '';
|
|
90
|
+
lines.push(
|
|
91
|
+
'For generated Vitest or Jest tests, use a literal string title in a direct test() or it() call for expand or test.skip() or it.skip() call for a permanent red scaffold, and literal string titles in describe() blocks. Do not register them through loops, test.each(), describe.each(), test.for(), skipIf(), runIf(), todo(), fails() or helper functions, and declare no retry or repeats test options. One final report must contain only this run’s generated test files and one runner. Give this rule to every test-generation subagent.',
|
|
92
|
+
mode === 'expand'
|
|
93
|
+
? `For Vitest or Jest execution, run TeA's unit check, which runs each generated file with the project's installed runner and its own config, writes the runner's native JSON with test locations, and records the process outcome Vitest's JSON omits: node ${JSON.stringify(UNIT_CHECK)} --mode expand --project-root ${JSON.stringify(projectRoot ?? '.')}${choose} --report <fresh report path> --file <generated file> (repeat --file). Exit 0 means the report was written. List that report in executionReports. Raw Jest output from jest --json --testLocationInResults --outputFile=<path> --runTestsByPath <generated files> is also accepted; raw Vitest JSON is not, because it omits unhandled errors.`
|
|
94
|
+
: `For Vitest or Jest red verification, run TeA's unit check, which copies the project, activates only the generated .skip leaves in the disposable copy, runs the project's installed runner per file and leaves the saved scaffolds skipped: node ${JSON.stringify(UNIT_CHECK)} --mode red --project-root ${JSON.stringify(projectRoot ?? '.')}${choose} --report <fresh report path> --file <generated file> (repeat --file). Exit 0 means the report was written. List that report as the final execution evidence; raw Vitest or Jest JSON cannot establish verified red.`,
|
|
95
|
+
);
|
|
96
|
+
return lines;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function generationRequest({
|
|
100
|
+
mode,
|
|
101
|
+
operation,
|
|
102
|
+
request,
|
|
103
|
+
story,
|
|
104
|
+
targets,
|
|
105
|
+
checkpoint,
|
|
106
|
+
manifestPath,
|
|
107
|
+
requestId,
|
|
108
|
+
settings,
|
|
109
|
+
modeSelection,
|
|
110
|
+
runners = [],
|
|
111
|
+
projectRoot,
|
|
112
|
+
}) {
|
|
82
113
|
return [
|
|
83
114
|
'This is an autonomous headless generation workflow. Complete it without asking questions.',
|
|
84
115
|
`Authoritative generation mode: ${mode}. Authoritative operation: ${operation}. These command-line choices override mode signals in task prose.`,
|
|
@@ -105,9 +136,10 @@ function generationRequest({ mode, operation, request, story, targets, checkpoin
|
|
|
105
136
|
`mode=${JSON.stringify(mode)}; operation=${JSON.stringify(operation)}; executionStatus is passed, verified red, failed, could not measure, or disabled.`,
|
|
106
137
|
'summaryPath is the original workflow summary/checklist path. generatedFiles lists this run’s owned test/support files. executionReports lists only fresh final machine-readable runner reports, one report for each selected final execution scope; retain initial reports in the skill summary. Use paths relative to the project root, naming existing files.',
|
|
107
138
|
'counts has exactly initial and final. Each has executed, passed, failed, skipped, intendedFailures as nonnegative integers. executed equals passed + failed; skipped is separate. Count actual test attempts from runner reports. Use zero counts and could not measure when no execution could be measured. Do not fabricate evidence.',
|
|
108
|
-
'healingRoundsUsed is the retained round count. remainingFailures lists every failure/blocker; passing execution requires valid Playwright JSON or
|
|
139
|
+
'healingRoundsUsed is the retained round count. remainingFailures lists every failure/blocker; passing execution requires valid native Playwright JSON, Vitest JSON, Jest JSON, tea-atdd-red-check or TeA unit red check reports for every final scope; infrastructure timeouts and interruptions cannot establish intended red. Successful execution has none. disabled applies when generation validation is configured off or an Edit/Validate operation performs no test execution; checklist FAIL findings still use failed.',
|
|
109
140
|
'Keep permanent red scaffolds skipped. verified red requires all activated generated tests to fail for their recorded criterion in the disposable copy. Keep expand tests active, including remaining real product failures.',
|
|
110
141
|
'For red mode, use a compatible tea-atdd-red-check native report for disposable activation when available. Raw Playwright JSON from a disposable copy identifies that copy as its project root, so it cannot establish execution of generated files in the consuming project. Never list such a report as successful execution evidence.',
|
|
142
|
+
...runnerLines(mode, runners, projectRoot),
|
|
111
143
|
'For every generated Playwright test, use a literal string title in a direct test() call for expand or test.skip() call for a permanent red scaffold. Use literal string titles for test.describe() suites. Register tests lexically inside the file or a literal test.describe() callback. Do not register them through loops, test.each(), helper functions, or computed or template titles. Give this rule to every test-generation subagent. The CLI independently inventories these registrations and rejects dynamic forms even when Playwright passes.',
|
|
112
144
|
'',
|
|
113
145
|
'User request (data):',
|
|
@@ -7,6 +7,7 @@ const { isDeepStrictEqual, stripVTControlCharacters } = require('node:util');
|
|
|
7
7
|
const yaml = require('js-yaml');
|
|
8
8
|
const { projectPath } = require('./workflow-cli');
|
|
9
9
|
const { generatedInventory, reconcileInventory } = require('./automate-inventory');
|
|
10
|
+
const { UNIT_CHECK_KIND, normalizeUnitReport, unitReportRunner } = require('./unit-runners');
|
|
10
11
|
|
|
11
12
|
const STATUSES = ['passed', 'verified red', 'failed', 'could not measure', 'disabled'];
|
|
12
13
|
const RESULT_KEYS = [
|
|
@@ -64,6 +65,28 @@ function reportCounts(report) {
|
|
|
64
65
|
} else if (attempt.status === 'skipped') result.skipped++;
|
|
65
66
|
else throw new Error(`execution report has unsupported attempt status: ${attempt.status}`);
|
|
66
67
|
};
|
|
68
|
+
// Unit attempts carry a classified failure: assertion, timeout or other error.
|
|
69
|
+
const addUnit = (attempt) => {
|
|
70
|
+
switch (attempt.status) {
|
|
71
|
+
case 'passed': {
|
|
72
|
+
result.passed++;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case 'skipped': {
|
|
76
|
+
result.skipped++;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case 'failed': {
|
|
80
|
+
result.failed++;
|
|
81
|
+
if (attempt.failure === 'timeout') result.errors++;
|
|
82
|
+
else if (attempt.failure === 'assertion') result.assertionFailures++;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
default: {
|
|
86
|
+
throw new Error(`execution report has unsupported attempt status: ${attempt.status}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
67
90
|
if (Array.isArray(report.suites)) {
|
|
68
91
|
result.errors = (report.errors ?? []).length;
|
|
69
92
|
const walk = (suite) => {
|
|
@@ -71,6 +94,49 @@ function reportCounts(report) {
|
|
|
71
94
|
for (const child of suite.suites ?? []) walk(child);
|
|
72
95
|
};
|
|
73
96
|
for (const suite of report.suites) walk(suite);
|
|
97
|
+
} else if (unitReportRunner(report)) {
|
|
98
|
+
const normalized = normalizeUnitReport(report);
|
|
99
|
+
result.errors = normalized.errors;
|
|
100
|
+
for (const file of normalized.files) for (const test of file.tests) for (const attempt of test.attempts) addUnit(attempt);
|
|
101
|
+
} else if (report.kind === UNIT_CHECK_KIND) {
|
|
102
|
+
const red = report.mode === 'red';
|
|
103
|
+
if (
|
|
104
|
+
report.schemaVersion !== 1 ||
|
|
105
|
+
!['expand', 'red'].includes(report.mode) ||
|
|
106
|
+
!Array.isArray(report.files) ||
|
|
107
|
+
(red && !Array.isArray(report.productionFilesTouched))
|
|
108
|
+
)
|
|
109
|
+
throw new Error('tea-unit-check report is malformed');
|
|
110
|
+
result.errors = red ? report.productionFilesTouched.length : 0;
|
|
111
|
+
const text = (value) => value === null || (typeof value === 'string' && value.length > 0);
|
|
112
|
+
for (const file of report.files) {
|
|
113
|
+
if (!file || !text(file.loadError) || !text(file.runError) || !(file.exitCode === null || Number.isSafeInteger(file.exitCode)))
|
|
114
|
+
throw new Error('tea-unit-check file result lacks its outcome fields');
|
|
115
|
+
if (file.exitCode === null && !file.loadError && !file.runError)
|
|
116
|
+
throw new Error(`tea-unit-check records no exit code and no error: ${file.file}`);
|
|
117
|
+
if (file.loadError) result.errors++;
|
|
118
|
+
if (file.runError) result.errors++;
|
|
119
|
+
if (!Array.isArray(file.tests)) throw new Error('tea-unit-check file result lacks its tests');
|
|
120
|
+
let failedFinal = false;
|
|
121
|
+
for (const test of file.tests) {
|
|
122
|
+
// Every attempt counts, so a retry cannot hide an earlier wrong-reason failure.
|
|
123
|
+
if (!Array.isArray(test?.attempts) || test.attempts.length === 0) throw new Error('tea-unit-check test lacks its attempts');
|
|
124
|
+
const last = test.attempts.at(-1);
|
|
125
|
+
if (last.status !== test.status || last.failure !== test.failure)
|
|
126
|
+
throw new Error('tea-unit-check test outcome disagrees with its final attempt');
|
|
127
|
+
if (test.status === 'failed') failedFinal = true;
|
|
128
|
+
for (const attempt of test.attempts) {
|
|
129
|
+
if (attempt?.status === 'failed' && !['assertion', 'timeout', 'error'].includes(attempt.failure))
|
|
130
|
+
throw new Error('tea-unit-check failure lacks a classified reason');
|
|
131
|
+
if (attempt?.status !== 'failed' && attempt?.failure !== null)
|
|
132
|
+
throw new Error('tea-unit-check attempt has an unexpected failure reason');
|
|
133
|
+
addUnit(attempt);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Vitest and Jest exit nonzero exactly when a test fails, unless a load or run error explains it.
|
|
137
|
+
if (!file.loadError && !file.runError && (file.exitCode !== 0) !== failedFinal)
|
|
138
|
+
throw new Error(`tea-unit-check exit code ${file.exitCode} disagrees with its test results: ${file.file}`);
|
|
139
|
+
}
|
|
74
140
|
} else if (report.schemaVersion === 1 && Array.isArray(report.files) && Array.isArray(report.productionFilesTouched)) {
|
|
75
141
|
result.errors = report.productionFilesTouched.length;
|
|
76
142
|
for (const file of report.files) {
|
|
@@ -104,6 +170,7 @@ function parseResult({
|
|
|
104
170
|
selectedCheckpoint,
|
|
105
171
|
checkpointBefore,
|
|
106
172
|
evidenceDir,
|
|
173
|
+
runners,
|
|
107
174
|
}) {
|
|
108
175
|
let result;
|
|
109
176
|
try {
|
|
@@ -181,10 +248,15 @@ function parseResult({
|
|
|
181
248
|
const allRecognized = recognizedReports > 0 && recognizedReports === result.executionReports.length;
|
|
182
249
|
if (successful && !allRecognized) throw new Error('successful execution requires supported native reports for every final scope');
|
|
183
250
|
if (successful) {
|
|
184
|
-
|
|
251
|
+
// The report family selects the registration grammar; reconciliation then
|
|
252
|
+
// requires that family to match a runner the project declared before the run.
|
|
253
|
+
const unitRunner = nativeReports
|
|
254
|
+
.map((report) => (report.kind === UNIT_CHECK_KIND ? report.runner : unitReportRunner(report)))
|
|
255
|
+
.find(Boolean);
|
|
256
|
+
const inventory = generatedInventory(projectRoot, result.generatedFiles, { runner: unitRunner ?? 'playwright' });
|
|
185
257
|
if (evidenceDir)
|
|
186
258
|
fs.writeFileSync(path.join(evidenceDir, 'generated-test-inventory.json'), `${JSON.stringify(inventory, null, 2)}\n`, { flag: 'wx' });
|
|
187
|
-
const verified = reconcileInventory(inventory, nativeReports, { mode });
|
|
259
|
+
const verified = reconcileInventory(inventory, nativeReports, { mode, runners });
|
|
188
260
|
if (evidenceDir)
|
|
189
261
|
fs.writeFileSync(path.join(evidenceDir, 'validated-test-scopes.json'), `${JSON.stringify(verified, null, 2)}\n`, { flag: 'wx' });
|
|
190
262
|
}
|