bmad-method-test-architecture-enterprise 2.0.3 → 2.0.4-next.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 +272 -0
- package/cli/lib/agent-adapters.js +5 -3
- package/cli/lib/automate-inventory.js +412 -0
- package/cli/lib/automate-prompt.js +118 -0
- package/cli/lib/automate-result.js +311 -0
- package/cli/lib/resolve-tea-config.js +2 -0
- package/package.json +6 -2
- package/skills/bmad-testarch-automate/red/steps-c/step-04a-subagent-api-failing.md +1 -0
- package/skills/bmad-testarch-automate/red/steps-c/step-04b-subagent-e2e-failing.md +1 -0
- package/skills/bmad-testarch-automate/steps-c/step-03a-subagent-api.md +1 -0
- package/skills/bmad-testarch-automate/steps-c/step-03b-subagent-e2e.md +1 -0
- package/skills/bmod-tea/bmod.toml +1 -1
|
@@ -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. The CI and ATDD commands remain available with existing customizations. Part of the BMad Method ecosystem.",
|
|
34
|
-
"version": "2.0.
|
|
34
|
+
"version": "2.0.4-next.0",
|
|
35
35
|
"author": {
|
|
36
36
|
"name": "Murat K Ozcan (TEA Creator) & Brian (BMad) Madison"
|
|
37
37
|
},
|
package/cli/automate.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { randomUUID } = require('node:crypto');
|
|
7
|
+
const { Command } = require('commander');
|
|
8
|
+
const { addAgentOptions, agentOptions, projectPath, resolveWorkflowSkill, headlessPrompt, runWithEvidence } = require('./lib/workflow-cli');
|
|
9
|
+
const { artifactPath, parseResult, frontmatter } = require('./lib/automate-result');
|
|
10
|
+
const { resolveGenerationConfig, generationRequest } = require('./lib/automate-prompt');
|
|
11
|
+
|
|
12
|
+
const NAME = 'tea-automate';
|
|
13
|
+
const MODES = ['red', 'expand'];
|
|
14
|
+
const OPERATIONS = ['create', 'resume', 'validate', 'edit'];
|
|
15
|
+
|
|
16
|
+
function collect(value, previous) {
|
|
17
|
+
return [...previous, value];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function safeTarget(root, value) {
|
|
21
|
+
const realRoot = fs.realpathSync(root);
|
|
22
|
+
const target = fs.realpathSync(path.resolve(realRoot, value));
|
|
23
|
+
const relative = path.relative(realRoot, target);
|
|
24
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
25
|
+
throw new Error(`target is outside the project: ${value}`);
|
|
26
|
+
return relative.split(path.sep).join('/') || '.';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resultDestination(root, value, protectedFiles) {
|
|
30
|
+
const output = projectPath(root, value, '--json');
|
|
31
|
+
if (path.extname(output) !== '.json') throw new Error('--json must name a .json result separate from inputs');
|
|
32
|
+
const existing = fs.existsSync(output) ? fs.statSync(output, { bigint: true }) : null;
|
|
33
|
+
if (existing && !existing.isFile()) throw new Error('--json must name a regular file');
|
|
34
|
+
const canonical = existing ? fs.realpathSync(output) : output;
|
|
35
|
+
for (const file of protectedFiles.filter(Boolean)) {
|
|
36
|
+
if (typeof file === 'object') {
|
|
37
|
+
if (canonical === file.path || (existing && `${existing.dev}:${existing.ino}` === file.identity))
|
|
38
|
+
throw new Error('--json collides with a protected input or artifact');
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const input = path.resolve(root, file);
|
|
42
|
+
if (!fs.existsSync(input)) continue;
|
|
43
|
+
const stat = fs.statSync(input, { bigint: true });
|
|
44
|
+
if (canonical === fs.realpathSync(input) || (existing && existing.dev === stat.dev && existing.ino === stat.ino))
|
|
45
|
+
throw new Error('--json collides with a protected input or artifact');
|
|
46
|
+
}
|
|
47
|
+
return output;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function freezeInputs(root, files) {
|
|
51
|
+
const protectedFiles = [];
|
|
52
|
+
const visited = new Set();
|
|
53
|
+
const visit = (file) => {
|
|
54
|
+
if (!fs.existsSync(file)) return;
|
|
55
|
+
const real = fs.realpathSync(file);
|
|
56
|
+
const stat = fs.statSync(real, { bigint: true });
|
|
57
|
+
const identity = `${stat.dev}:${stat.ino}`;
|
|
58
|
+
if (stat.isFile()) protectedFiles.push({ path: real, identity });
|
|
59
|
+
if (!stat.isDirectory() || visited.has(identity)) return;
|
|
60
|
+
visited.add(identity);
|
|
61
|
+
for (const child of fs.readdirSync(real)) visit(path.join(real, child));
|
|
62
|
+
};
|
|
63
|
+
for (const file of files.filter(Boolean)) visit(path.resolve(root, file));
|
|
64
|
+
return protectedFiles;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function selectMode(explicit, request, savedMode) {
|
|
68
|
+
if (savedMode) {
|
|
69
|
+
if (explicit && explicit !== savedMode) throw new Error(`checkpoint belongs to ${savedMode} mode; pass --mode ${savedMode}`);
|
|
70
|
+
return { mode: savedMode, selection: 'checkpoint' };
|
|
71
|
+
}
|
|
72
|
+
if (explicit) return { mode: explicit, selection: 'explicit' };
|
|
73
|
+
const red = /\b(?:red mode|ATDD|TDD red|write acceptance tests|acceptance test scaffolds|acceptance tests before implementation)\b/i.test(
|
|
74
|
+
request,
|
|
75
|
+
);
|
|
76
|
+
const expand =
|
|
77
|
+
/\b(?:expand mode|coverage expansion|expand (?:test )?coverage|tests for (?:existing|implemented) code|automation)\b/i.test(request);
|
|
78
|
+
if (red !== expand) return { mode: red ? 'red' : 'expand', selection: 'request' };
|
|
79
|
+
return { mode: 'expand', selection: 'entry-default' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function main(argv) {
|
|
83
|
+
const program = new Command();
|
|
84
|
+
program
|
|
85
|
+
.name(NAME)
|
|
86
|
+
.description(
|
|
87
|
+
'Generate red acceptance scaffolds or expand coverage with the packaged merged Automate skill, including default run-and-heal.',
|
|
88
|
+
)
|
|
89
|
+
.argument('[request]', 'generation request; otherwise read UTF-8 text from standard input')
|
|
90
|
+
.option('--mode <mode>', 'generation mode (red|expand); infer from request or checkpoint, default expand')
|
|
91
|
+
.option('--operation <operation>', 'workflow operation (create|resume|validate|edit)', 'create')
|
|
92
|
+
.option('--project-root <dir>', 'consuming project directory', process.cwd())
|
|
93
|
+
.option('--story <path>', 'story with acceptance criteria; required for red Create')
|
|
94
|
+
.option('--target <path>', 'target file or directory inside the project (repeatable)', collect, [])
|
|
95
|
+
.option('--checkpoint <path>', 'exact saved artifact for Resume, Validate or Edit')
|
|
96
|
+
.option('--skill-root <dir>', 'explicit canonical Automate skill directory')
|
|
97
|
+
.option('--project-skill', 'use the project-installed canonical skill')
|
|
98
|
+
.option('--evidence-dir <dir>', 'directory for fresh per-attempt prompts, streams and manifests')
|
|
99
|
+
.option('--json <path>', 'write validated result JSON to this file')
|
|
100
|
+
.option('--allow-network', 'allow unrestricted outbound network access in the Codex workspace sandbox')
|
|
101
|
+
.exitOverride()
|
|
102
|
+
.configureOutput({ writeErr: () => {} });
|
|
103
|
+
addAgentOptions(program);
|
|
104
|
+
program.setOptionValueWithSource('retries', '0', 'default');
|
|
105
|
+
try {
|
|
106
|
+
program.parse(argv);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error.exitCode === 0) return 0;
|
|
109
|
+
process.stderr.write(`${NAME}: ${error.message}\n`);
|
|
110
|
+
return 2;
|
|
111
|
+
}
|
|
112
|
+
const options = program.opts();
|
|
113
|
+
let root;
|
|
114
|
+
let skillRoot;
|
|
115
|
+
let request;
|
|
116
|
+
let story;
|
|
117
|
+
let checkpoint;
|
|
118
|
+
let targets;
|
|
119
|
+
let resolvedConfig;
|
|
120
|
+
let modeSelection;
|
|
121
|
+
let checkpointBefore;
|
|
122
|
+
let protectedFiles;
|
|
123
|
+
try {
|
|
124
|
+
if (options.mode !== undefined && !MODES.includes(options.mode)) throw new Error(`--mode must be ${MODES.join(' or ')}`);
|
|
125
|
+
if (!OPERATIONS.includes(options.operation)) throw new Error(`--operation must be ${OPERATIONS.join(', ')}`);
|
|
126
|
+
if (options.allowNetwork && options.agent !== 'codex') throw new Error('--allow-network requires --agent codex');
|
|
127
|
+
root = fs.realpathSync(path.resolve(options.projectRoot));
|
|
128
|
+
agentOptions(options, root);
|
|
129
|
+
if (Number(options.retries) > 0 && options.operation !== 'validate')
|
|
130
|
+
throw new Error('generation operations require --retries 0; resume a retained checkpoint explicitly after interruption');
|
|
131
|
+
if (options.evidenceDir) projectPath(root, options.evidenceDir, '--evidence-dir');
|
|
132
|
+
if (options.json) projectPath(root, options.json, '--json');
|
|
133
|
+
if (!fs.statSync(root).isDirectory()) throw new Error('--project-root must be a directory');
|
|
134
|
+
if (options.skillRoot && options.projectSkill) throw new Error('--skill-root and --project-skill are mutually exclusive');
|
|
135
|
+
if (options.projectSkill) {
|
|
136
|
+
const candidates = ['.claude/skills', '.agents/skills', 'skills', '_bmad/tea/workflows/testarch'];
|
|
137
|
+
options.skillRoot = candidates
|
|
138
|
+
.map((directory) => path.join(root, directory, 'bmad-testarch-automate'))
|
|
139
|
+
.find((candidate) => fs.existsSync(path.join(candidate, 'SKILL.md')));
|
|
140
|
+
if (!options.skillRoot) throw new Error('project-installed bmad-testarch-automate skill is missing');
|
|
141
|
+
}
|
|
142
|
+
skillRoot = resolveWorkflowSkill({ projectRoot: root, skillName: 'bmad-testarch-automate', skillRoot: options.skillRoot });
|
|
143
|
+
if (!fs.existsSync(path.join(skillRoot, '..', 'bmod-tea', 'knowledge', 'tea-index.csv')))
|
|
144
|
+
throw new Error('the selected skill has no sibling bmod-tea knowledge base');
|
|
145
|
+
if (options.story) story = artifactPath(root, options.story, '--story');
|
|
146
|
+
if (options.checkpoint) checkpoint = artifactPath(root, options.checkpoint, '--checkpoint');
|
|
147
|
+
targets = options.target.map((target) => safeTarget(root, target));
|
|
148
|
+
protectedFiles = freezeInputs(root, [story, checkpoint, 'package.json', ...targets]);
|
|
149
|
+
if (options.json) resultDestination(root, options.json, protectedFiles);
|
|
150
|
+
if (options.operation !== 'create' && !checkpoint) throw new Error(`--checkpoint is required for ${options.operation}`);
|
|
151
|
+
if (options.operation === 'create' && checkpoint) throw new Error('--checkpoint requires Resume, Validate or Edit');
|
|
152
|
+
let savedMode;
|
|
153
|
+
if (checkpoint) {
|
|
154
|
+
checkpointBefore = frontmatter(path.join(root, checkpoint));
|
|
155
|
+
const saved = checkpointBefore.state;
|
|
156
|
+
if (options.operation === 'resume') {
|
|
157
|
+
if (story && (saved.cli_story === undefined ? !(saved.inputDocuments ?? []).includes(story) : saved.cli_story !== story))
|
|
158
|
+
throw new Error('Resume story differs from its saved scope');
|
|
159
|
+
if (targets.length > 0 && JSON.stringify(saved.cli_targets) !== JSON.stringify(targets))
|
|
160
|
+
throw new Error('Resume targets differ from its saved scope');
|
|
161
|
+
protectedFiles.push(...freezeInputs(root, [saved.cli_story, ...(saved.cli_targets ?? [])]));
|
|
162
|
+
if (options.json) resultDestination(root, options.json, protectedFiles);
|
|
163
|
+
}
|
|
164
|
+
savedMode = saved?.testMode ?? saved?.test_mode;
|
|
165
|
+
if (savedMode && !MODES.includes(savedMode)) throw new Error('checkpoint has an invalid generation mode');
|
|
166
|
+
if (!savedMode) savedMode = /(?:^|\/)atdd(?:\/|$)|(?:^|\/)atdd-checklist-/.test(checkpoint) ? 'red' : 'expand';
|
|
167
|
+
}
|
|
168
|
+
request = program.args[0] ?? (process.stdin.isTTY ? '' : new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(0)));
|
|
169
|
+
const selected = selectMode(options.mode, request, savedMode);
|
|
170
|
+
options.mode = selected.mode;
|
|
171
|
+
modeSelection = selected.selection;
|
|
172
|
+
if (options.mode === 'red' && options.operation === 'create' && !story) throw new Error('--story is required for red Create');
|
|
173
|
+
if (!request.trim() && !story && targets.length === 0 && !checkpoint)
|
|
174
|
+
throw new Error('supply a request, --story, --target or --checkpoint');
|
|
175
|
+
if (options.operation === 'edit' && !request.trim()) throw new Error('Edit requires instructions in the request or standard input');
|
|
176
|
+
resolvedConfig = resolveGenerationConfig(
|
|
177
|
+
root,
|
|
178
|
+
checkpoint ? path.join(root, checkpoint) : undefined,
|
|
179
|
+
options.operation,
|
|
180
|
+
skillRoot,
|
|
181
|
+
options.mode,
|
|
182
|
+
);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
process.stderr.write(`${NAME}: ${error.message}\n`);
|
|
185
|
+
return 2;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const evidenceRoot = path.resolve(root, options.evidenceDir ?? path.join('_bmad-output', 'test-artifacts', 'automate-cli', randomUUID()));
|
|
189
|
+
const prepare = ({ attemptDir }) => {
|
|
190
|
+
const manifestPath = path.join(attemptDir, 'generation.json');
|
|
191
|
+
const requestId = randomUUID();
|
|
192
|
+
const prompt = headlessPrompt({
|
|
193
|
+
skillRoot,
|
|
194
|
+
projectRoot: root,
|
|
195
|
+
resolvedConfig,
|
|
196
|
+
operation: options.operation,
|
|
197
|
+
requestLines: generationRequest({
|
|
198
|
+
mode: options.mode,
|
|
199
|
+
operation: options.operation,
|
|
200
|
+
request,
|
|
201
|
+
story,
|
|
202
|
+
targets,
|
|
203
|
+
checkpoint,
|
|
204
|
+
manifestPath,
|
|
205
|
+
requestId,
|
|
206
|
+
settings: resolvedConfig.settings,
|
|
207
|
+
modeSelection,
|
|
208
|
+
}),
|
|
209
|
+
});
|
|
210
|
+
return { prompt, manifestPath, requestId, startedAtMs: Date.now() };
|
|
211
|
+
};
|
|
212
|
+
if (options.agent === 'none') {
|
|
213
|
+
process.stdout.write(`${prepare({ attemptDir: path.join(evidenceRoot, 'attempt-1') }).prompt}\n`);
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
if (options.allowNetwork) options.agentArg = ['-c', 'sandbox_workspace_write.network_access=true', ...options.agentArg];
|
|
217
|
+
try {
|
|
218
|
+
const result = await runWithEvidence({
|
|
219
|
+
name: NAME,
|
|
220
|
+
projectRoot: root,
|
|
221
|
+
evidenceRoot,
|
|
222
|
+
options,
|
|
223
|
+
prepare,
|
|
224
|
+
capabilities: ['command-execution'],
|
|
225
|
+
validate: ({ manifestPath, startedAtMs, requestId, attemptDir }) =>
|
|
226
|
+
parseResult({
|
|
227
|
+
manifestPath,
|
|
228
|
+
projectRoot: root,
|
|
229
|
+
mode: options.mode,
|
|
230
|
+
operation: options.operation,
|
|
231
|
+
settings: resolvedConfig.settings,
|
|
232
|
+
startedAtMs,
|
|
233
|
+
requestId,
|
|
234
|
+
story,
|
|
235
|
+
targets,
|
|
236
|
+
selectedCheckpoint: checkpoint,
|
|
237
|
+
checkpointBefore,
|
|
238
|
+
evidenceDir: attemptDir,
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
const payload = { ...result.value, modeSelection, evidenceDir: result.runDir, agent: options.agent };
|
|
242
|
+
const text = `${JSON.stringify(payload, null, 2)}\n`;
|
|
243
|
+
if (options.json) {
|
|
244
|
+
const output = resultDestination(root, options.json, [
|
|
245
|
+
...protectedFiles,
|
|
246
|
+
payload.summaryPath,
|
|
247
|
+
...payload.generatedFiles,
|
|
248
|
+
...payload.executionReports,
|
|
249
|
+
payload.validationReportPath,
|
|
250
|
+
]);
|
|
251
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
252
|
+
fs.writeFileSync(output, text);
|
|
253
|
+
}
|
|
254
|
+
process.stdout.write(text);
|
|
255
|
+
return ['failed', 'could not measure'].includes(payload.executionStatus) || payload.remainingFailures.length > 0 ? 1 : 0;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
process.stderr.write(`${NAME}: ${error.message}\nEvidence: ${evidenceRoot}\n`);
|
|
258
|
+
return 3;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (require.main === module)
|
|
263
|
+
main(process.argv)
|
|
264
|
+
.then((code) => {
|
|
265
|
+
process.exitCode = code;
|
|
266
|
+
})
|
|
267
|
+
.catch((error) => {
|
|
268
|
+
process.stderr.write(`${NAME}: ${error.message}\n`);
|
|
269
|
+
process.exitCode = 3;
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
module.exports = { main, MODES, OPERATIONS, selectMode };
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* here makes the local run and the CI run the same run. --model overrides it.
|
|
36
36
|
*
|
|
37
37
|
* The pinned values are aliases, not immutable snapshots: "sonnet" follows
|
|
38
|
-
* Anthropic's current Sonnet and "gpt-
|
|
38
|
+
* Anthropic's current Sonnet and "gpt-6.1-sol" is a family slug. They hold the
|
|
39
39
|
* tier steady, not the exact weights. Pass a fully-qualified slug to --model
|
|
40
40
|
* when a run has to be reproducible across model generations.
|
|
41
41
|
*
|
|
@@ -343,7 +343,7 @@ const AGENT_ADAPTERS = {
|
|
|
343
343
|
},
|
|
344
344
|
codex: {
|
|
345
345
|
command: 'codex',
|
|
346
|
-
defaultModel: 'gpt-
|
|
346
|
+
defaultModel: 'gpt-6.1-sol',
|
|
347
347
|
modelFlags: ['-m', '--model'],
|
|
348
348
|
// `codex exec` reads the prompt from stdin when no PROMPT arg is given.
|
|
349
349
|
// --sandbox workspace-write grants read/write/exec inside cwd without
|
|
@@ -351,7 +351,9 @@ const AGENT_ADAPTERS = {
|
|
|
351
351
|
// a workspace-write file write completes with no approval prompt and no
|
|
352
352
|
// TTY, because approval is only for escalating past the sandbox. A caller
|
|
353
353
|
// declaring read-only gets --sandbox read-only instead, which is the
|
|
354
|
-
// vendor's own enforcement of that tier.
|
|
354
|
+
// vendor's own enforcement of that tier. Command execution does not
|
|
355
|
+
// grant network access by itself. A caller can opt in through `extra`;
|
|
356
|
+
// Codex grants unrestricted outbound access when that setting is true.
|
|
355
357
|
// --skip-git-repo-check matters under --isolate, where the agent's cwd
|
|
356
358
|
// is a fresh tmpdir with no .git.
|
|
357
359
|
//
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { createHash } = require('node:crypto');
|
|
6
|
+
const { parse } = require('@babel/parser');
|
|
7
|
+
const { minimatch } = require('minimatch');
|
|
8
|
+
|
|
9
|
+
const slash = (value) => value.split(path.sep).join('/');
|
|
10
|
+
const canonical = (value) => {
|
|
11
|
+
let ancestor = path.resolve(value);
|
|
12
|
+
const suffix = [];
|
|
13
|
+
while (!fs.existsSync(ancestor)) {
|
|
14
|
+
const parent = path.dirname(ancestor);
|
|
15
|
+
if (parent === ancestor) break;
|
|
16
|
+
suffix.unshift(path.basename(ancestor));
|
|
17
|
+
ancestor = parent;
|
|
18
|
+
}
|
|
19
|
+
return path.resolve(fs.realpathSync(ancestor), ...suffix);
|
|
20
|
+
};
|
|
21
|
+
const literal = (node) =>
|
|
22
|
+
node?.type === 'StringLiteral'
|
|
23
|
+
? node.value
|
|
24
|
+
: node?.type === 'TemplateLiteral' && node.expressions.length === 0
|
|
25
|
+
? node.quasis[0].value.cooked
|
|
26
|
+
: null;
|
|
27
|
+
|
|
28
|
+
// JSONReporter serializes both globs and RegExp instances with toString().
|
|
29
|
+
// Apply the runner's absolute-path, reset-lastIndex and case-insensitive glob rules.
|
|
30
|
+
function nativeMatches(patterns, absolute) {
|
|
31
|
+
if (!Array.isArray(patterns)) throw new Error('native Playwright selectors must be arrays');
|
|
32
|
+
return patterns.some((pattern) => {
|
|
33
|
+
if (typeof pattern !== 'string') throw new Error('native Playwright selector is invalid');
|
|
34
|
+
const serialized = pattern.match(/^\/(.*)\/([dgimsuvy]*)$/s);
|
|
35
|
+
if (serialized) {
|
|
36
|
+
let expression;
|
|
37
|
+
try {
|
|
38
|
+
expression = new RegExp(serialized[1], serialized[2]);
|
|
39
|
+
} catch {
|
|
40
|
+
throw new Error('native Playwright RegExp selector is invalid');
|
|
41
|
+
}
|
|
42
|
+
expression.lastIndex = 0;
|
|
43
|
+
if (expression.test(absolute)) return true;
|
|
44
|
+
expression.lastIndex = 0;
|
|
45
|
+
return path.sep === '\\' && expression.test(slash(absolute));
|
|
46
|
+
}
|
|
47
|
+
return minimatch(absolute, pattern.startsWith('**/') ? pattern : `**/${pattern}`, { nocase: true, dot: true });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Track lexical declarations so a shadowed name cannot inherit a test binding.
|
|
52
|
+
function sourceBindings(ast, file, projectRoot) {
|
|
53
|
+
const scopes = new WeakMap();
|
|
54
|
+
const root = { parent: null, bindings: new Map(), functionScope: true };
|
|
55
|
+
const assignments = [];
|
|
56
|
+
const children = (node, visit) => {
|
|
57
|
+
for (const [key, value] of Object.entries(node)) {
|
|
58
|
+
if (['loc', 'start', 'end'].includes(key)) continue;
|
|
59
|
+
if (Array.isArray(value)) for (const child of value) visit(child);
|
|
60
|
+
else if (value && typeof value === 'object') visit(value);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const declare = (pattern, scope, init, property) => {
|
|
64
|
+
if (!pattern) return;
|
|
65
|
+
switch (pattern.type) {
|
|
66
|
+
case 'Identifier': {
|
|
67
|
+
scope.bindings.set(pattern.name, { init, property });
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case 'ObjectPattern': {
|
|
71
|
+
for (const item of pattern.properties) declare(item.value ?? item.argument, scope, init, item.key?.name ?? literal(item.key));
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case 'AssignmentPattern': {
|
|
75
|
+
declare(pattern.left, scope, init, property);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case 'ArrayPattern': {
|
|
79
|
+
for (const item of pattern.elements) declare(item, scope, null);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case 'RestElement': {
|
|
83
|
+
declare(pattern.argument, scope, null);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
default: {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const build = (node, scope, parentNode) => {
|
|
92
|
+
if (!node || typeof node !== 'object') return;
|
|
93
|
+
if (node.type === 'FunctionDeclaration' && node.id) declare(node.id, scope, null);
|
|
94
|
+
if (node.type === 'ClassDeclaration' && node.id) declare(node.id, scope, null);
|
|
95
|
+
if (node.type === 'BlockStatement' || /Function/.test(node.type) || node.type === 'CatchClause') {
|
|
96
|
+
scope = { parent: scope, bindings: new Map(), functionScope: /Function/.test(node.type) };
|
|
97
|
+
for (const parameter of node.params ?? []) declare(parameter, scope, null);
|
|
98
|
+
declare(node.param, scope, null);
|
|
99
|
+
}
|
|
100
|
+
scopes.set(node, scope);
|
|
101
|
+
if (node.type === 'ImportDeclaration')
|
|
102
|
+
for (const item of node.specifiers) {
|
|
103
|
+
const imported = ['ImportNamespaceSpecifier', 'ImportDefaultSpecifier'].includes(item.type) ? 'namespace' : item.imported?.name;
|
|
104
|
+
scope.bindings.set(item.local.name, {
|
|
105
|
+
kind: imported === 'test' ? 'test' : imported === 'mergeTests' ? 'merge' : imported === 'namespace' ? 'namespace' : 'other',
|
|
106
|
+
imported: true,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (node.type === 'VariableDeclarator') {
|
|
110
|
+
let declarationScope = scope;
|
|
111
|
+
if (parentNode?.kind === 'var') while (!declarationScope.functionScope) declarationScope = declarationScope.parent;
|
|
112
|
+
declare(node.id, declarationScope, node.init);
|
|
113
|
+
}
|
|
114
|
+
if (node.type === 'AssignmentExpression' && node.left.type === 'Identifier') assignments.push(node);
|
|
115
|
+
children(node, (child) => build(child, scope, node));
|
|
116
|
+
};
|
|
117
|
+
build(ast.program, root);
|
|
118
|
+
const findBinding = (node) => {
|
|
119
|
+
let scope = scopes.get(node);
|
|
120
|
+
while (scope && !scope.bindings.has(node.name)) scope = scope.parent;
|
|
121
|
+
return scope?.bindings.get(node.name);
|
|
122
|
+
};
|
|
123
|
+
for (const assignment of assignments) {
|
|
124
|
+
const binding = findBinding(assignment.left);
|
|
125
|
+
if (binding) (binding.assignments ??= []).push(assignment.right);
|
|
126
|
+
}
|
|
127
|
+
let moduleSource = /\.(?:mjs|mts)$/.test(file);
|
|
128
|
+
if (!/\.(?:[cm]js|[cm]ts)$/.test(file)) {
|
|
129
|
+
let directory = path.dirname(path.resolve(projectRoot, file));
|
|
130
|
+
while (true) {
|
|
131
|
+
const pkg = path.join(directory, 'package.json');
|
|
132
|
+
if (fs.existsSync(pkg)) {
|
|
133
|
+
moduleSource = JSON.parse(fs.readFileSync(pkg, 'utf8')).type === 'module';
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
const parent = path.dirname(directory);
|
|
137
|
+
if (parent === directory) break;
|
|
138
|
+
directory = parent;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const memberName = (node) => (node.computed ? literal(node.property) : node.property.name);
|
|
142
|
+
const resolve = (node, seen = new Set()) => {
|
|
143
|
+
if (!node) return null;
|
|
144
|
+
if (node.type === 'Identifier') {
|
|
145
|
+
const binding = findBinding(node);
|
|
146
|
+
// Bare test is retained for native ATDD/global test source inventories.
|
|
147
|
+
if (!binding) return node.name === 'test' ? { kind: 'test', importEnd: true } : null;
|
|
148
|
+
if (seen.has(binding)) return null;
|
|
149
|
+
seen = new Set([...seen, binding]);
|
|
150
|
+
if (
|
|
151
|
+
binding.assignments?.length &&
|
|
152
|
+
(['test', 'namespace', 'merge'].includes(binding.kind) ||
|
|
153
|
+
['test', 'namespace', 'merge'].includes(resolve(binding.init, seen)?.kind) ||
|
|
154
|
+
binding.assignments.some((value) => ['test', 'namespace', 'merge'].includes(resolve(value, seen)?.kind)))
|
|
155
|
+
)
|
|
156
|
+
throw new Error(`generated test inventory has reassigned test binding: ${file}`);
|
|
157
|
+
if (binding.kind) return { kind: binding.kind, importEnd: binding.imported && !moduleSource };
|
|
158
|
+
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;
|
|
163
|
+
return value ? { kind: value.kind } : null;
|
|
164
|
+
}
|
|
165
|
+
if (node.type === 'MemberExpression') {
|
|
166
|
+
const value = resolve(node.object, seen);
|
|
167
|
+
if (value?.kind === 'namespace' && node.computed && memberName(node) === null)
|
|
168
|
+
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' };
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
if (node.type === 'CallExpression') {
|
|
174
|
+
if (node.callee.type === 'Identifier' && node.callee.name === 'require' && literal(node.arguments[0]) !== null)
|
|
175
|
+
return { kind: 'namespace' };
|
|
176
|
+
if (resolve(node.callee, seen)?.kind === 'merge') {
|
|
177
|
+
if (node.arguments.length === 0 || node.arguments.some((argument) => resolve(argument, seen)?.kind !== 'test'))
|
|
178
|
+
throw new Error(`generated test inventory has unresolved merged binding: ${file}`);
|
|
179
|
+
return { kind: 'test' };
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
node.callee.type === 'MemberExpression' &&
|
|
183
|
+
memberName(node.callee) === 'extend' &&
|
|
184
|
+
resolve(node.callee.object, seen)?.kind === 'test'
|
|
185
|
+
)
|
|
186
|
+
return { kind: 'test' };
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
};
|
|
190
|
+
const registration = (node) => {
|
|
191
|
+
const value = resolve(node);
|
|
192
|
+
if (value?.kind === 'test') return { binding: value, chain: [], base: node };
|
|
193
|
+
if (node?.type === 'MemberExpression') {
|
|
194
|
+
const parent = registration(node.object);
|
|
195
|
+
if (parent) return { ...parent, chain: [...parent.chain, memberName(node)] };
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
};
|
|
199
|
+
const location = (callee, binding) => {
|
|
200
|
+
// Native member calls point at the property. Transformed named imports map
|
|
201
|
+
// the generated indirect call to callee-end; local/CJS/ESM calls use start.
|
|
202
|
+
const point = callee.type === 'MemberExpression' ? callee.property.loc.start : binding.importEnd ? callee.loc.end : callee.loc.start;
|
|
203
|
+
return { line: point.line, column: point.column + 1 };
|
|
204
|
+
};
|
|
205
|
+
return { registration, location };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Enumerate registrations without executing adopter code. Runtime enumeration is
|
|
209
|
+
// deliberately unmeasured: a numerical report cannot prove its missing leaves.
|
|
210
|
+
function generatedInventory(projectRoot, files) {
|
|
211
|
+
const inventory = {
|
|
212
|
+
schemaVersion: 1,
|
|
213
|
+
basis: 'post-generation source syntax',
|
|
214
|
+
projectRoot: fs.realpathSync(projectRoot),
|
|
215
|
+
files: [],
|
|
216
|
+
tests: [],
|
|
217
|
+
};
|
|
218
|
+
for (const file of files) {
|
|
219
|
+
const bytes = fs.readFileSync(path.join(projectRoot, file));
|
|
220
|
+
inventory.files.push({ file, sha256: createHash('sha256').update(bytes).digest('hex') });
|
|
221
|
+
if (!/\.(?:[cm]?[jt]sx?)$/.test(file)) continue;
|
|
222
|
+
const ast = parse(bytes.toString('utf8'), { sourceType: 'unambiguous', plugins: ['typescript', 'jsx'] });
|
|
223
|
+
const { registration, location } = sourceBindings(ast, file, projectRoot);
|
|
224
|
+
const walk = (node, titles = [], dynamic = false) => {
|
|
225
|
+
if (!node || typeof node !== 'object') return;
|
|
226
|
+
if (node.type === 'CallExpression') {
|
|
227
|
+
const resolved = registration(node.callee);
|
|
228
|
+
if (
|
|
229
|
+
!resolved &&
|
|
230
|
+
!dynamic &&
|
|
231
|
+
literal(node.arguments[0]) !== null &&
|
|
232
|
+
node.arguments.some((arg) => ['ArrowFunctionExpression', 'FunctionExpression'].includes(arg.type))
|
|
233
|
+
)
|
|
234
|
+
throw new Error(`generated test inventory has unresolved registration binding: ${file}`);
|
|
235
|
+
if (resolved) {
|
|
236
|
+
const chain = ['test', ...resolved.chain];
|
|
237
|
+
if (chain.includes(null)) throw new Error(`generated test inventory has computed registration: ${file}`);
|
|
238
|
+
if (chain[1] === 'each') throw new Error(`generated test inventory has unresolved table registration: ${file}`);
|
|
239
|
+
if (chain[1] === 'describe' && ['describe', 'only', 'skip', 'serial', 'parallel'].includes(chain.at(-1))) {
|
|
240
|
+
const title = literal(node.arguments[0]);
|
|
241
|
+
const callback = node.arguments.find((arg) => ['ArrowFunctionExpression', 'FunctionExpression'].includes(arg.type));
|
|
242
|
+
if (title === null || !callback || dynamic) throw new Error(`generated test inventory has dynamic suite registration: ${file}`);
|
|
243
|
+
walk(callback.body, [...titles, title], false);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (chain.length === 1 || (chain.length === 2 && ['only', 'skip', 'fixme', 'fail'].includes(chain[1]))) {
|
|
247
|
+
// test.skip(condition, reason) inside a test is metadata, never a leaf.
|
|
248
|
+
const callback = node.arguments.find((arg) => ['ArrowFunctionExpression', 'FunctionExpression'].includes(arg.type));
|
|
249
|
+
if (callback) {
|
|
250
|
+
const title = literal(node.arguments[0]);
|
|
251
|
+
if (title === null || dynamic) throw new Error(`generated test inventory has dynamic test registration: ${file}`);
|
|
252
|
+
inventory.tests.push({
|
|
253
|
+
file,
|
|
254
|
+
titlePath: [...titles, title],
|
|
255
|
+
...location(node.callee, resolved.binding),
|
|
256
|
+
...(chain[1] === 'skip'
|
|
257
|
+
? {
|
|
258
|
+
activationLine: location(resolved.base, resolved.binding).line,
|
|
259
|
+
activationColumn: location(resolved.base, resolved.binding).column,
|
|
260
|
+
}
|
|
261
|
+
: {}),
|
|
262
|
+
});
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (chain.length === 1 || literal(node.arguments[0]) !== null)
|
|
266
|
+
throw new Error(`generated test inventory has unresolved callback registration: ${file}`);
|
|
267
|
+
}
|
|
268
|
+
if (
|
|
269
|
+
node.arguments.some((arg) => ['ArrowFunctionExpression', 'FunctionExpression'].includes(arg.type)) &&
|
|
270
|
+
!['beforeEach', 'beforeAll', 'afterEach', 'afterAll', 'step', 'extend'].includes(chain[1])
|
|
271
|
+
)
|
|
272
|
+
throw new Error(`generated test inventory has unsupported registration: ${file}`);
|
|
273
|
+
if (
|
|
274
|
+
chain.length > 1 &&
|
|
275
|
+
![
|
|
276
|
+
'describe',
|
|
277
|
+
'only',
|
|
278
|
+
'skip',
|
|
279
|
+
'fixme',
|
|
280
|
+
'fail',
|
|
281
|
+
'beforeEach',
|
|
282
|
+
'beforeAll',
|
|
283
|
+
'afterEach',
|
|
284
|
+
'afterAll',
|
|
285
|
+
'step',
|
|
286
|
+
'extend',
|
|
287
|
+
'use',
|
|
288
|
+
'info',
|
|
289
|
+
'setTimeout',
|
|
290
|
+
'slow',
|
|
291
|
+
'expect',
|
|
292
|
+
].includes(chain[1])
|
|
293
|
+
)
|
|
294
|
+
throw new Error(`generated test inventory has unresolved registration: ${file}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const nestedDynamic = dynamic || /(?:Function|Method|For|While|If|Conditional|Switch)/.test(node.type);
|
|
298
|
+
for (const [key, value] of Object.entries(node)) {
|
|
299
|
+
if (['loc', 'start', 'end'].includes(key)) continue;
|
|
300
|
+
if (Array.isArray(value)) for (const child of value) walk(child, titles, nestedDynamic);
|
|
301
|
+
else if (value && typeof value === 'object') walk(value, titles, nestedDynamic);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
walk(ast.program);
|
|
305
|
+
}
|
|
306
|
+
if (inventory.tests.length === 0) throw new Error('generated test inventory contains no measurable test leaves');
|
|
307
|
+
return inventory;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function reconcileInventory(inventory, reports, { mode, reportProjectRoot = inventory.projectRoot } = {}) {
|
|
311
|
+
const covered = new Map();
|
|
312
|
+
const projectsByFile = new Map();
|
|
313
|
+
if (reports.some((report) => Array.isArray(report.suites)) && reports.some((report) => Array.isArray(report.files)))
|
|
314
|
+
throw new Error('final execution mixes incompatible runner scope identities');
|
|
315
|
+
const identity = (leaf, project) => JSON.stringify([leaf.file, leaf.titlePath, leaf.line, leaf.column, project]);
|
|
316
|
+
const playwrightLeaves = new Map();
|
|
317
|
+
const atddLeaves = new Map();
|
|
318
|
+
const sourceKey = (file, titles, line, column) => JSON.stringify([file, titles, line, column]);
|
|
319
|
+
const index = (map, key, leaf) => map.set(key, [...(map.get(key) ?? []), leaf]);
|
|
320
|
+
for (const leaf of inventory.tests) {
|
|
321
|
+
index(playwrightLeaves, sourceKey(leaf.file, leaf.titlePath, leaf.line, leaf.column), leaf);
|
|
322
|
+
if (mode === 'red' && leaf.activationLine !== undefined)
|
|
323
|
+
index(playwrightLeaves, sourceKey(leaf.file, leaf.titlePath, leaf.activationLine, leaf.activationColumn), leaf);
|
|
324
|
+
index(atddLeaves, JSON.stringify([leaf.file, leaf.titlePath.at(-1)]), leaf);
|
|
325
|
+
}
|
|
326
|
+
for (const report of reports) {
|
|
327
|
+
if (Array.isArray(report.suites)) {
|
|
328
|
+
if (
|
|
329
|
+
!report.config?.configFile ||
|
|
330
|
+
!report.config.rootDir ||
|
|
331
|
+
!Array.isArray(report.config.projects) ||
|
|
332
|
+
report.config.projects.length === 0
|
|
333
|
+
)
|
|
334
|
+
throw new Error('native Playwright evidence lacks file and project identity configuration');
|
|
335
|
+
if (!reportProjectRoot) throw new Error('native execution reconciliation requires the consuming project root');
|
|
336
|
+
const base = canonical(reportProjectRoot);
|
|
337
|
+
const confined = (absolute) => {
|
|
338
|
+
const relative = path.relative(base, absolute);
|
|
339
|
+
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
340
|
+
};
|
|
341
|
+
if (!confined(canonical(report.config.configFile)) || !confined(canonical(report.config.rootDir)))
|
|
342
|
+
throw new Error('native Playwright evidence identifies a different consuming project root');
|
|
343
|
+
const reportedFile = (file) =>
|
|
344
|
+
slash(path.relative(base, canonical(path.isAbsolute(file) ? file : path.resolve(report.config.rootDir, file))));
|
|
345
|
+
const projectIds = report.config.projects.map((project) => project.id ?? project.name);
|
|
346
|
+
if (projectIds.some((id) => typeof id !== 'string') || new Set(projectIds).size !== projectIds.length)
|
|
347
|
+
throw new Error('native Playwright project identities are missing or duplicated');
|
|
348
|
+
// Every configured matching project is part of the generated execution scope.
|
|
349
|
+
for (const leaf of inventory.tests) {
|
|
350
|
+
const projects = projectsByFile.get(leaf.file) ?? new Map();
|
|
351
|
+
for (const project of report.config.projects) {
|
|
352
|
+
const relative = slash(path.relative(canonical(project.testDir ?? report.config.rootDir), path.resolve(base, leaf.file)));
|
|
353
|
+
if (relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)) continue;
|
|
354
|
+
const matches = (patterns) => nativeMatches(patterns, path.resolve(base, leaf.file));
|
|
355
|
+
if ((project.testMatch?.length && !matches(project.testMatch)) || (project.testIgnore?.length && matches(project.testIgnore)))
|
|
356
|
+
continue;
|
|
357
|
+
const repeatEach = project.repeatEach ?? 1;
|
|
358
|
+
if (!Number.isSafeInteger(repeatEach) || repeatEach < 1) throw new Error('native Playwright repeatEach must be positive');
|
|
359
|
+
const id = project.id ?? project.name;
|
|
360
|
+
if (projects.has(id) && projects.get(id) !== repeatEach) throw new Error('final reports disagree on project repetition scope');
|
|
361
|
+
projects.set(id, repeatEach);
|
|
362
|
+
}
|
|
363
|
+
projectsByFile.set(leaf.file, projects);
|
|
364
|
+
}
|
|
365
|
+
const scopesInReport = new Map();
|
|
366
|
+
const walk = (suite, titles) => {
|
|
367
|
+
const nextTitles = suite.line > 0 && suite.title ? [...titles, suite.title] : titles;
|
|
368
|
+
for (const spec of suite.specs ?? []) {
|
|
369
|
+
const file = reportedFile(spec.file ?? suite.file ?? '');
|
|
370
|
+
const titlePath = [...nextTitles, spec.title];
|
|
371
|
+
const leaves = playwrightLeaves.get(sourceKey(file, titlePath, spec.line, spec.column)) ?? [];
|
|
372
|
+
if (leaves.length !== 1) throw new Error(`runner result does not identify one generated test leaf: ${file} ${spec.title ?? ''}`);
|
|
373
|
+
for (const test of spec.tests ?? []) {
|
|
374
|
+
const project = test.projectId ?? test.projectName;
|
|
375
|
+
if (!projectIds.includes(project) || !projectsByFile.get(file)?.has(project))
|
|
376
|
+
throw new Error(`runner result has an unrelated project: ${file}`);
|
|
377
|
+
if (!test.results?.length) throw new Error(`runner result has no attempts: ${file} ${spec.title}`);
|
|
378
|
+
const key = identity(leaves[0], project);
|
|
379
|
+
scopesInReport.set(key, (scopesInReport.get(key) ?? 0) + 1);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
for (const child of suite.suites ?? []) walk(child, nextTitles);
|
|
383
|
+
};
|
|
384
|
+
for (const suite of report.suites) walk(suite, []);
|
|
385
|
+
for (const [key, count] of scopesInReport) {
|
|
386
|
+
if (covered.has(key)) throw new Error('final runner reports repeat a generated test/project scope');
|
|
387
|
+
covered.set(key, count);
|
|
388
|
+
}
|
|
389
|
+
} else if (report.schemaVersion === 1 && Array.isArray(report.files)) {
|
|
390
|
+
for (const leaf of inventory.tests) projectsByFile.set(leaf.file, new Map([['native-atdd', 1]]));
|
|
391
|
+
for (const file of report.files)
|
|
392
|
+
for (const test of file.tests ?? []) {
|
|
393
|
+
const leaves = atddLeaves.get(JSON.stringify([slash(file.file ?? ''), test.title])) ?? [];
|
|
394
|
+
if (leaves.length !== 1)
|
|
395
|
+
throw new Error(`native ATDD result does not identify one generated test leaf: ${file.file ?? ''} ${test.title ?? ''}`);
|
|
396
|
+
const key = identity(leaves[0], 'native-atdd');
|
|
397
|
+
if (covered.has(key)) throw new Error('final runner reports repeat a generated test scope');
|
|
398
|
+
covered.set(key, 1);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
for (const leaf of inventory.tests) {
|
|
403
|
+
const projects = projectsByFile.get(leaf.file);
|
|
404
|
+
if (!projects?.size) throw new Error(`generated test file has no selected runner project: ${leaf.file}`);
|
|
405
|
+
for (const [project, repetitions] of projects)
|
|
406
|
+
if (covered.get(identity(leaf, project)) !== repetitions)
|
|
407
|
+
throw new Error(`final execution omits generated test/project scope: ${leaf.file} ${leaf.titlePath.join(' > ')} [${project}]`);
|
|
408
|
+
}
|
|
409
|
+
return { ...inventory, executionScopes: [...covered].map(([key, repetitions]) => ({ identity: JSON.parse(key), repetitions })) };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
module.exports = { generatedInventory, reconcileInventory };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const yaml = require('js-yaml');
|
|
6
|
+
const TOML = require('smol-toml');
|
|
7
|
+
const { readTeaConfigFile, readWorkflowCustomization, configSource, MODULE_DEFAULTS, SNAPSHOT_DEFAULTS } = require('./resolve-tea-config');
|
|
8
|
+
const { RESULT_KEYS } = require('./automate-result');
|
|
9
|
+
|
|
10
|
+
const HEALING_DEFAULTS = { auto_validate: true, auto_heal_failures: true, max_healing_iterations: 3, use_mcp_healing: true };
|
|
11
|
+
|
|
12
|
+
function boolean(value, key) {
|
|
13
|
+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : value;
|
|
14
|
+
if (normalized === true || normalized === 'true') return true;
|
|
15
|
+
if (normalized === false || normalized === 'false') return false;
|
|
16
|
+
throw new Error(`${key} must be true or false`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resolveGenerationConfig(projectRoot, checkpointPath, operation, skillRoot, mode) {
|
|
20
|
+
const file = readTeaConfigFile(projectRoot);
|
|
21
|
+
const tea = {
|
|
22
|
+
...SNAPSHOT_DEFAULTS,
|
|
23
|
+
...file.tea,
|
|
24
|
+
...MODULE_DEFAULTS,
|
|
25
|
+
...file.values,
|
|
26
|
+
tea_browser_automation: file.tea.tea_browser_automation ?? 'none',
|
|
27
|
+
};
|
|
28
|
+
if (!['none', 'auto', 'cli', 'mcp'].includes(tea.tea_browser_automation))
|
|
29
|
+
throw new Error('tea_browser_automation must be none, auto, cli or mcp');
|
|
30
|
+
const namespace = mode === 'red' ? 'bmad-testarch-atdd' : 'bmad-testarch-automate';
|
|
31
|
+
const sibling = path.join(skillRoot, '..', namespace, 'customize.toml');
|
|
32
|
+
const defaultsPath =
|
|
33
|
+
mode === 'red' && !fs.existsSync(sibling)
|
|
34
|
+
? path.join(skillRoot, 'red', 'customize.toml')
|
|
35
|
+
: path.join(mode === 'red' ? path.dirname(sibling) : skillRoot, 'customize.toml');
|
|
36
|
+
const workflowCustomization = readWorkflowCustomization(configSource(projectRoot), path.dirname(defaultsPath), projectRoot, namespace);
|
|
37
|
+
if (!workflowCustomization || typeof workflowCustomization !== 'object' || Array.isArray(workflowCustomization))
|
|
38
|
+
throw new Error('selected workflow customization must contain a workflow table');
|
|
39
|
+
const settings = { ...HEALING_DEFAULTS };
|
|
40
|
+
for (const key of Object.keys(settings)) {
|
|
41
|
+
const raw = tea[key] ?? settings[key];
|
|
42
|
+
if (key === 'max_healing_iterations') {
|
|
43
|
+
if (!/^[0-3]$/.test(String(raw))) throw new Error(`${key} must be an integer from 0 through 3`);
|
|
44
|
+
settings[key] = Number(raw);
|
|
45
|
+
} else settings[key] = boolean(raw, key);
|
|
46
|
+
}
|
|
47
|
+
if (operation === 'resume') {
|
|
48
|
+
const text = fs.readFileSync(checkpointPath, 'utf8');
|
|
49
|
+
const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
50
|
+
if (frontmatter) {
|
|
51
|
+
const saved = yaml.load(frontmatter[1]);
|
|
52
|
+
for (const key of Object.keys(settings)) {
|
|
53
|
+
if (saved?.[key] === undefined) continue;
|
|
54
|
+
if (key === 'max_healing_iterations') {
|
|
55
|
+
if (!Number.isSafeInteger(saved[key]) || saved[key] < 0 || saved[key] > 3)
|
|
56
|
+
throw new Error(`checkpoint ${key} must be an integer from 0 through 3`);
|
|
57
|
+
settings[key] = saved[key];
|
|
58
|
+
} else settings[key] = boolean(saved[key], `checkpoint ${key}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
workflowCustomization,
|
|
64
|
+
configSnapshot: {
|
|
65
|
+
core: { user_name: 'User', communication_language: 'English', document_output_language: 'English', ...file.core },
|
|
66
|
+
modules: { tea: { ...tea, ...settings } },
|
|
67
|
+
},
|
|
68
|
+
settings,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function defaultsAgree(moduleRoot) {
|
|
73
|
+
const source = TOML.parse(fs.readFileSync(path.join(moduleRoot, 'bmod.toml'), 'utf8'));
|
|
74
|
+
return Object.fromEntries(
|
|
75
|
+
source.bmod.config_questions
|
|
76
|
+
.filter((question) => Object.hasOwn(HEALING_DEFAULTS, question.key))
|
|
77
|
+
.map((question) => [question.key, question.default]),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function generationRequest({ mode, operation, request, story, targets, checkpoint, manifestPath, requestId, settings, modeSelection }) {
|
|
82
|
+
return [
|
|
83
|
+
'This is an autonomous headless generation workflow. Complete it without asking questions.',
|
|
84
|
+
`Authoritative generation mode: ${mode}. Authoritative operation: ${operation}. These command-line choices override mode signals in task prose.`,
|
|
85
|
+
`Mode selection basis: ${modeSelection}. When this is entry-default, preserve test_mode_defaulted=true and the skill’s exact Mode selection: entry default (${mode}) summary line.`,
|
|
86
|
+
story ? `Story path: ${JSON.stringify(story)}.` : '',
|
|
87
|
+
targets.length > 0 ? `Target scope: ${JSON.stringify(targets)}. Read these targets and their requirements; preserve this scope.` : '',
|
|
88
|
+
checkpoint
|
|
89
|
+
? `Exact saved artifact: ${JSON.stringify(checkpoint)}. Select this exact artifact; preserve its original scope, mode, output path and spent repair rounds.`
|
|
90
|
+
: '',
|
|
91
|
+
'Keep the canonical Automate skill root in both modes. Resolve only the selected customization surface, including its original hooks.',
|
|
92
|
+
'The headless config snapshot supplied above is authoritative for activation Step 4. Setup scripts and interactive setup are unnecessary for this CLI run.',
|
|
93
|
+
`Resolved Create settings: ${JSON.stringify(settings)}. Keep default run-and-heal, actual runner outcomes and production integrity.`,
|
|
94
|
+
'Create and Resume continue the owning generation flow and save its completed YAML checkpoint. Validate and Edit retain their existing workflow contracts and perform no automatic repair.',
|
|
95
|
+
mode === 'red'
|
|
96
|
+
? 'Finish the red Create or Resume workflow through step-05-validate-and-complete before publishing the manifest. The saved checklist frontmatter must have workflowStatus: completed; stepsCompleted must retain step-01-preflight-and-context, step-02-generation-mode, step-03-test-strategy, step-04c-aggregate (or step-04-generate-tests), and step-05-validate-and-complete; lastStep must be step-05-validate-and-complete. Record only steps actually completed during this run.'
|
|
97
|
+
: 'Finish the expand Create or Resume workflow through step-04-validate-and-summarize before publishing the manifest. The saved summary frontmatter must have workflowStatus: completed; stepsCompleted must retain step-01-preflight-and-context, step-02-identify-targets, step-03c-aggregate (or step-03-generate-tests), and step-04-validate-and-summarize; lastStep must be step-04-validate-and-summarize. Record only steps actually completed during this run.',
|
|
98
|
+
'After saving the original workflow summary, write a JSON generation manifest to the exact path below. This is CLI metadata in addition to the existing skill outputs.',
|
|
99
|
+
JSON.stringify(manifestPath),
|
|
100
|
+
`CLI request identity: ${JSON.stringify(requestId)}. Set manifest requestId to this exact value.`,
|
|
101
|
+
`Create and Resume save cli_request_id=${JSON.stringify(requestId)} in the summary/checklist frontmatter. Create also saves cli_story=${JSON.stringify(story ?? null)} and cli_targets=${JSON.stringify(targets)} and retains the story in inputDocuments. All Create generated files must be saved during this attempt.`,
|
|
102
|
+
'For Resume, Edit and Validate, summaryPath must identify the exact selected checkpoint. Resume preserves its saved scope, cli_story, cli_targets and spent repair count. Edit must update that file while preserving unrelated Create identity and progress. Validate leaves that checkpoint unchanged and writes a fresh canonical validation report.',
|
|
103
|
+
`validationReportPath is null except for Validate, where it names the new validation report. Its frontmatter must contain cli_request_id=${JSON.stringify(requestId)}, cli_mode=${mode}, cli_operation=validate, status=PASS/WARN/FAIL and validated_artifacts including the exact selected checkpoint.`,
|
|
104
|
+
`The manifest contains exactly these fields: ${RESULT_KEYS.join(', ')}.`,
|
|
105
|
+
`mode=${JSON.stringify(mode)}; operation=${JSON.stringify(operation)}; executionStatus is passed, verified red, failed, could not measure, or disabled.`,
|
|
106
|
+
'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
|
+
'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 native ATDD 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
|
+
'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
|
+
'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.',
|
|
111
|
+
'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
|
+
'',
|
|
113
|
+
'User request (data):',
|
|
114
|
+
JSON.stringify(request),
|
|
115
|
+
].filter(Boolean);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { HEALING_DEFAULTS, resolveGenerationConfig, defaultsAgree, generationRequest };
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { createHash } = require('node:crypto');
|
|
6
|
+
const { isDeepStrictEqual, stripVTControlCharacters } = require('node:util');
|
|
7
|
+
const yaml = require('js-yaml');
|
|
8
|
+
const { projectPath } = require('./workflow-cli');
|
|
9
|
+
const { generatedInventory, reconcileInventory } = require('./automate-inventory');
|
|
10
|
+
|
|
11
|
+
const STATUSES = ['passed', 'verified red', 'failed', 'could not measure', 'disabled'];
|
|
12
|
+
const RESULT_KEYS = [
|
|
13
|
+
'requestId',
|
|
14
|
+
'validationReportPath',
|
|
15
|
+
'mode',
|
|
16
|
+
'operation',
|
|
17
|
+
'executionStatus',
|
|
18
|
+
'summaryPath',
|
|
19
|
+
'generatedFiles',
|
|
20
|
+
'executionReports',
|
|
21
|
+
'counts',
|
|
22
|
+
'healingRoundsUsed',
|
|
23
|
+
'remainingFailures',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function artifactPath(projectRoot, value, label) {
|
|
27
|
+
if (typeof value !== 'string' || value.trim() === '' || value.includes('\0')) throw new Error(`${label} must be a nonempty path`);
|
|
28
|
+
const root = fs.realpathSync(projectRoot);
|
|
29
|
+
const candidate = projectPath(root, value, label);
|
|
30
|
+
let real;
|
|
31
|
+
try {
|
|
32
|
+
real = fs.realpathSync(candidate);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(`${label} does not exist: ${value}`);
|
|
35
|
+
}
|
|
36
|
+
const resolvedRelative = path.relative(root, real);
|
|
37
|
+
if (resolvedRelative === '..' || resolvedRelative.startsWith(`..${path.sep}`) || path.isAbsolute(resolvedRelative))
|
|
38
|
+
throw new Error(`${label} resolves outside the project`);
|
|
39
|
+
if (!fs.statSync(real).isFile()) throw new Error(`${label} must name a file: ${value}`);
|
|
40
|
+
return path.relative(root, real).split(path.sep).join('/');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function counts(value, label) {
|
|
44
|
+
const keys = ['executed', 'passed', 'failed', 'skipped', 'intendedFailures'];
|
|
45
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length)
|
|
46
|
+
throw new Error(`${label} must contain exactly ${keys.join(', ')}`);
|
|
47
|
+
for (const key of keys)
|
|
48
|
+
if (!Number.isSafeInteger(value[key]) || value[key] < 0) throw new Error(`${label}.${key} must be a nonnegative integer`);
|
|
49
|
+
if (value.executed !== value.passed + value.failed) throw new Error(`${label}.executed must equal passed + failed`);
|
|
50
|
+
if (value.intendedFailures > value.failed) throw new Error(`${label}.intendedFailures exceeds failed`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function reportCounts(report) {
|
|
54
|
+
if (!report || typeof report !== 'object' || Array.isArray(report)) return null;
|
|
55
|
+
const result = { executed: 0, passed: 0, failed: 0, skipped: 0, errors: 0, assertionFailures: 0 };
|
|
56
|
+
const assertion = (error) =>
|
|
57
|
+
error?.matcherResult || /(?:^|\n)(?:Error: )?expect\(/.test(stripVTControlCharacters(String(error?.message ?? '')));
|
|
58
|
+
const add = (attempt) => {
|
|
59
|
+
if (attempt.status === 'passed') result.passed++;
|
|
60
|
+
else if (['failed', 'timedOut', 'interrupted'].includes(attempt.status)) {
|
|
61
|
+
result.failed++;
|
|
62
|
+
if (attempt.status !== 'failed') result.errors++;
|
|
63
|
+
else if ((attempt.errors ?? []).some(assertion)) result.assertionFailures++;
|
|
64
|
+
} else if (attempt.status === 'skipped') result.skipped++;
|
|
65
|
+
else throw new Error(`execution report has unsupported attempt status: ${attempt.status}`);
|
|
66
|
+
};
|
|
67
|
+
if (Array.isArray(report.suites)) {
|
|
68
|
+
result.errors = (report.errors ?? []).length;
|
|
69
|
+
const walk = (suite) => {
|
|
70
|
+
for (const spec of suite.specs ?? []) for (const test of spec.tests ?? []) for (const attempt of test.results ?? []) add(attempt);
|
|
71
|
+
for (const child of suite.suites ?? []) walk(child);
|
|
72
|
+
};
|
|
73
|
+
for (const suite of report.suites) walk(suite);
|
|
74
|
+
} else if (report.schemaVersion === 1 && Array.isArray(report.files) && Array.isArray(report.productionFilesTouched)) {
|
|
75
|
+
result.errors = report.productionFilesTouched.length;
|
|
76
|
+
for (const file of report.files) {
|
|
77
|
+
if (file.loadError) result.errors++;
|
|
78
|
+
for (const test of file.tests ?? []) add({ ...test, errors: [{ message: test.message }] });
|
|
79
|
+
}
|
|
80
|
+
} else return null;
|
|
81
|
+
result.executed = result.passed + result.failed;
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function frontmatter(file) {
|
|
86
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
87
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
88
|
+
if (!match) throw new Error('summary must retain workflow YAML frontmatter');
|
|
89
|
+
const state = yaml.load(match[1]);
|
|
90
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) throw new Error('artifact frontmatter must be a table');
|
|
91
|
+
return { text, state, hash: createHash('sha256').update(text).digest('hex') };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseResult({
|
|
95
|
+
manifestPath,
|
|
96
|
+
projectRoot,
|
|
97
|
+
mode,
|
|
98
|
+
operation,
|
|
99
|
+
settings,
|
|
100
|
+
startedAtMs,
|
|
101
|
+
requestId,
|
|
102
|
+
story,
|
|
103
|
+
targets = [],
|
|
104
|
+
selectedCheckpoint,
|
|
105
|
+
checkpointBefore,
|
|
106
|
+
evidenceDir,
|
|
107
|
+
}) {
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw new Error(`generation manifest is missing or invalid JSON: ${error.message}`);
|
|
113
|
+
}
|
|
114
|
+
if (
|
|
115
|
+
!result ||
|
|
116
|
+
typeof result !== 'object' ||
|
|
117
|
+
Array.isArray(result) ||
|
|
118
|
+
Object.keys(result).length !== RESULT_KEYS.length ||
|
|
119
|
+
RESULT_KEYS.some((key) => !Object.hasOwn(result, key))
|
|
120
|
+
) {
|
|
121
|
+
throw new Error(`generation manifest must contain exactly ${RESULT_KEYS.join(', ')}`);
|
|
122
|
+
}
|
|
123
|
+
if (result.requestId !== requestId) throw new Error('generation manifest belongs to another CLI request');
|
|
124
|
+
if (operation !== 'validate' && result.validationReportPath !== null) throw new Error('validationReportPath is reserved for Validate');
|
|
125
|
+
if (result.mode !== mode || result.operation !== operation)
|
|
126
|
+
throw new Error('generation manifest mode or operation differs from the request');
|
|
127
|
+
if (!STATUSES.includes(result.executionStatus)) throw new Error(`unsupported execution status: ${result.executionStatus}`);
|
|
128
|
+
result.summaryPath = artifactPath(projectRoot, result.summaryPath, 'summaryPath');
|
|
129
|
+
if (selectedCheckpoint && result.summaryPath !== selectedCheckpoint)
|
|
130
|
+
throw new Error('result summary differs from the selected checkpoint');
|
|
131
|
+
for (const key of ['generatedFiles', 'executionReports']) {
|
|
132
|
+
if (!Array.isArray(result[key]) || new Set(result[key]).size !== result[key].length)
|
|
133
|
+
throw new Error(`${key} must be an array of unique paths`);
|
|
134
|
+
result[key] = result[key].map((file) => artifactPath(projectRoot, file, key));
|
|
135
|
+
if (new Set(result[key]).size !== result[key].length) throw new Error(`${key} contains duplicate resolved paths`);
|
|
136
|
+
}
|
|
137
|
+
if (
|
|
138
|
+
!Array.isArray(result.remainingFailures) ||
|
|
139
|
+
result.remainingFailures.some((failure) => typeof failure !== 'string' || !failure.trim())
|
|
140
|
+
)
|
|
141
|
+
throw new Error('remainingFailures must be an array of nonempty strings');
|
|
142
|
+
if (
|
|
143
|
+
!result.counts ||
|
|
144
|
+
Object.keys(result.counts).length !== 2 ||
|
|
145
|
+
!Object.hasOwn(result.counts, 'initial') ||
|
|
146
|
+
!Object.hasOwn(result.counts, 'final')
|
|
147
|
+
)
|
|
148
|
+
throw new Error('counts must contain initial and final');
|
|
149
|
+
counts(result.counts.initial, 'counts.initial');
|
|
150
|
+
counts(result.counts.final, 'counts.final');
|
|
151
|
+
const actual = { executed: 0, passed: 0, failed: 0, skipped: 0, errors: 0, assertionFailures: 0 };
|
|
152
|
+
const successful = ['passed', 'verified red'].includes(result.executionStatus);
|
|
153
|
+
let recognizedReports = 0;
|
|
154
|
+
const nativeReports = [];
|
|
155
|
+
const reportIdentities = new Set();
|
|
156
|
+
for (const file of result.executionReports) {
|
|
157
|
+
const absolute = path.join(projectRoot, file);
|
|
158
|
+
const stat = fs.statSync(absolute, { bigint: true });
|
|
159
|
+
const identity = `${stat.dev}:${stat.ino}`;
|
|
160
|
+
if (reportIdentities.has(identity)) throw new Error('executionReports repeats the same physical evidence through an alias');
|
|
161
|
+
reportIdentities.add(identity);
|
|
162
|
+
if (
|
|
163
|
+
(['create', 'resume'].includes(operation) || successful) &&
|
|
164
|
+
startedAtMs !== undefined &&
|
|
165
|
+
fs.statSync(absolute).mtimeMs < startedAtMs - 1
|
|
166
|
+
)
|
|
167
|
+
throw new Error(`execution evidence predates this attempt: ${file}`);
|
|
168
|
+
let report;
|
|
169
|
+
try {
|
|
170
|
+
report = JSON.parse(fs.readFileSync(absolute, 'utf8'));
|
|
171
|
+
} catch {
|
|
172
|
+
throw new Error(`execution report is invalid JSON: ${file}`);
|
|
173
|
+
}
|
|
174
|
+
const observed = reportCounts(report);
|
|
175
|
+
if (observed) {
|
|
176
|
+
recognizedReports++;
|
|
177
|
+
nativeReports.push(report);
|
|
178
|
+
for (const key of Object.keys(actual)) actual[key] += observed[key];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const allRecognized = recognizedReports > 0 && recognizedReports === result.executionReports.length;
|
|
182
|
+
if (successful && !allRecognized) throw new Error('successful execution requires supported native reports for every final scope');
|
|
183
|
+
if (successful) {
|
|
184
|
+
const inventory = generatedInventory(projectRoot, result.generatedFiles);
|
|
185
|
+
if (evidenceDir)
|
|
186
|
+
fs.writeFileSync(path.join(evidenceDir, 'generated-test-inventory.json'), `${JSON.stringify(inventory, null, 2)}\n`, { flag: 'wx' });
|
|
187
|
+
const verified = reconcileInventory(inventory, nativeReports, { mode });
|
|
188
|
+
if (evidenceDir)
|
|
189
|
+
fs.writeFileSync(path.join(evidenceDir, 'validated-test-scopes.json'), `${JSON.stringify(verified, null, 2)}\n`, { flag: 'wx' });
|
|
190
|
+
}
|
|
191
|
+
if (recognizedReports > 0) {
|
|
192
|
+
for (const key of ['executed', 'passed', 'failed', 'skipped'])
|
|
193
|
+
if (allRecognized ? actual[key] !== result.counts.final[key] : actual[key] > result.counts.final[key])
|
|
194
|
+
throw new Error(`counts.final.${key} disagrees with runner reports`);
|
|
195
|
+
if (actual.errors > 0 && successful) throw new Error('successful execution has runner errors or modified production files');
|
|
196
|
+
}
|
|
197
|
+
if (
|
|
198
|
+
!Number.isSafeInteger(result.healingRoundsUsed) ||
|
|
199
|
+
result.healingRoundsUsed < 0 ||
|
|
200
|
+
result.healingRoundsUsed > settings.max_healing_iterations
|
|
201
|
+
)
|
|
202
|
+
throw new Error('healingRoundsUsed exceeds the configured repair budget');
|
|
203
|
+
|
|
204
|
+
const summary = frontmatter(path.join(projectRoot, result.summaryPath));
|
|
205
|
+
const checkpoint = summary.state;
|
|
206
|
+
const savedMode = checkpoint.test_mode ?? checkpoint.testMode;
|
|
207
|
+
if (savedMode !== undefined && savedMode !== mode) throw new Error('selected artifact mode differs from the request');
|
|
208
|
+
if (operation === 'create') {
|
|
209
|
+
if (
|
|
210
|
+
checkpoint.cli_request_id !== requestId ||
|
|
211
|
+
checkpoint.cli_story !== (story ?? null) ||
|
|
212
|
+
!isDeepStrictEqual(checkpoint.cli_targets, targets)
|
|
213
|
+
)
|
|
214
|
+
throw new Error('Create summary does not identify this request and scope');
|
|
215
|
+
for (const file of [result.summaryPath, ...result.generatedFiles])
|
|
216
|
+
if (fs.statSync(path.join(projectRoot, file)).mtimeMs < startedAtMs - 1)
|
|
217
|
+
throw new Error(`generation artifact predates this attempt: ${file}`);
|
|
218
|
+
if (story && !(checkpoint.inputDocuments ?? []).includes(story)) throw new Error('Create summary does not retain the requested story');
|
|
219
|
+
}
|
|
220
|
+
if (selectedCheckpoint && checkpointBefore) {
|
|
221
|
+
if (operation === 'validate' && summary.hash !== checkpointBefore.hash) throw new Error('Validate modified the selected checkpoint');
|
|
222
|
+
if (operation === 'edit' && summary.hash === checkpointBefore.hash) throw new Error('Edit did not update the selected checkpoint');
|
|
223
|
+
const identityKeys = ['runScope', 'runKey', 'testEntry', 'testMode', 'test_mode', 'test_operation', 'cli_story', 'cli_targets'];
|
|
224
|
+
const progressKeys = [
|
|
225
|
+
'workflowStatus',
|
|
226
|
+
'stepsCompleted',
|
|
227
|
+
'lastStep',
|
|
228
|
+
'auto_validate',
|
|
229
|
+
'auto_heal_failures',
|
|
230
|
+
'max_healing_iterations',
|
|
231
|
+
'use_mcp_healing',
|
|
232
|
+
'healing_rounds_used',
|
|
233
|
+
];
|
|
234
|
+
for (const key of [...identityKeys, ...(operation === 'edit' ? progressKeys : [])])
|
|
235
|
+
if (checkpointBefore.state[key] !== undefined && !isDeepStrictEqual(checkpoint[key], checkpointBefore.state[key]))
|
|
236
|
+
throw new Error(`selected checkpoint changed its saved ${key}`);
|
|
237
|
+
if (operation === 'resume') {
|
|
238
|
+
if (checkpoint.cli_request_id !== requestId) throw new Error('Resume checkpoint does not identify this CLI attempt');
|
|
239
|
+
if (result.healingRoundsUsed < (checkpointBefore.state.healing_rounds_used ?? 0))
|
|
240
|
+
throw new Error('Resume reset its saved repair count');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (operation === 'validate') {
|
|
244
|
+
result.validationReportPath = artifactPath(projectRoot, result.validationReportPath, 'validationReportPath');
|
|
245
|
+
const validation = frontmatter(path.join(projectRoot, result.validationReportPath)).state;
|
|
246
|
+
if (
|
|
247
|
+
!['PASS', 'WARN', 'FAIL'].includes(validation.status) ||
|
|
248
|
+
validation.cli_request_id !== requestId ||
|
|
249
|
+
validation.cli_mode !== mode ||
|
|
250
|
+
validation.cli_operation !== 'validate'
|
|
251
|
+
)
|
|
252
|
+
throw new Error('validation report is incomplete or belongs to another request');
|
|
253
|
+
const selected = (validation.validated_artifacts ?? []).map((file) => artifactPath(projectRoot, file, 'validated_artifacts'));
|
|
254
|
+
if (!selected.includes(selectedCheckpoint)) throw new Error('validation report omits the selected checkpoint');
|
|
255
|
+
if (fs.statSync(path.join(projectRoot, result.validationReportPath)).mtimeMs < startedAtMs - 1)
|
|
256
|
+
throw new Error('validation report predates this attempt');
|
|
257
|
+
if (validation.status === 'FAIL' && result.executionStatus !== 'failed')
|
|
258
|
+
throw new Error('failed validation must report a failed outcome');
|
|
259
|
+
}
|
|
260
|
+
if (operation === 'create' || operation === 'resume') {
|
|
261
|
+
if (checkpoint?.workflowStatus !== 'completed')
|
|
262
|
+
throw new Error(`generation checkpoint is incomplete: workflowStatus is ${JSON.stringify(checkpoint?.workflowStatus ?? null)}`);
|
|
263
|
+
const terminal = mode === 'red' ? 'step-05-validate-and-complete' : 'step-04-validate-and-summarize';
|
|
264
|
+
const required =
|
|
265
|
+
mode === 'red'
|
|
266
|
+
? ['step-01-preflight-and-context', 'step-02-generation-mode', 'step-03-test-strategy', terminal]
|
|
267
|
+
: ['step-01-preflight-and-context', 'step-02-identify-targets', terminal];
|
|
268
|
+
const aggregation =
|
|
269
|
+
mode === 'red' ? ['step-04c-aggregate', 'step-04-generate-tests'] : ['step-03c-aggregate', 'step-03-generate-tests'];
|
|
270
|
+
const completed = Array.isArray(checkpoint.stepsCompleted) ? checkpoint.stepsCompleted : [];
|
|
271
|
+
const missing = required.filter((step) => !completed.includes(step));
|
|
272
|
+
if (!aggregation.some((step) => completed.includes(step))) missing.push(`one of ${aggregation.join(' or ')}`);
|
|
273
|
+
if (missing.length > 0 || checkpoint.lastStep !== terminal)
|
|
274
|
+
throw new Error(
|
|
275
|
+
`generation checkpoint does not retain completed mode-specific steps and terminal step: missing ${missing.join(', ') || 'none'}; lastStep ${JSON.stringify(checkpoint.lastStep ?? null)}; expected ${terminal}`,
|
|
276
|
+
);
|
|
277
|
+
if (result.generatedFiles.length === 0) throw new Error('generation completed without generated files');
|
|
278
|
+
if (checkpoint.test_mode !== mode || checkpoint.test_operation !== 'create')
|
|
279
|
+
throw new Error('generation checkpoint has inconsistent mode or operation');
|
|
280
|
+
for (const key of ['auto_validate', 'auto_heal_failures', 'max_healing_iterations', 'use_mcp_healing']) {
|
|
281
|
+
if (checkpoint[key] !== settings[key]) throw new Error(`generation checkpoint changed ${key}`);
|
|
282
|
+
}
|
|
283
|
+
if (checkpoint.healing_rounds_used !== result.healingRoundsUsed)
|
|
284
|
+
throw new Error('generation checkpoint and manifest disagree on repair rounds');
|
|
285
|
+
if (
|
|
286
|
+
(!settings.auto_validate || !settings.auto_heal_failures) &&
|
|
287
|
+
result.healingRoundsUsed !== (operation === 'resume' ? (checkpointBefore?.state.healing_rounds_used ?? 0) : 0)
|
|
288
|
+
)
|
|
289
|
+
throw new Error('disabled healing cannot consume repair rounds');
|
|
290
|
+
if (!settings.auto_validate && result.executionStatus !== 'disabled') throw new Error('disabled validation must report disabled');
|
|
291
|
+
if (settings.auto_validate && result.executionStatus === 'disabled')
|
|
292
|
+
throw new Error('validation was enabled but execution reports disabled');
|
|
293
|
+
}
|
|
294
|
+
const final = result.counts.final;
|
|
295
|
+
if (['passed', 'verified red'].includes(result.executionStatus)) {
|
|
296
|
+
if (final.executed === 0 || final.skipped !== 0 || result.executionReports.length === 0)
|
|
297
|
+
throw new Error('successful execution requires executed tests, reports, and no skipped results');
|
|
298
|
+
if (result.executionStatus === 'passed' && (mode !== 'expand' || final.failed !== 0))
|
|
299
|
+
throw new Error('passed requires expand mode with no failed tests');
|
|
300
|
+
if (
|
|
301
|
+
result.executionStatus === 'verified red' &&
|
|
302
|
+
(mode !== 'red' || final.passed !== 0 || final.intendedFailures !== final.failed || actual.assertionFailures !== final.failed)
|
|
303
|
+
)
|
|
304
|
+
throw new Error('verified red requires every executed test to fail for its intended criterion');
|
|
305
|
+
}
|
|
306
|
+
if (['failed', 'could not measure'].includes(result.executionStatus) && result.remainingFailures.length === 0)
|
|
307
|
+
throw new Error('failed or unmeasured execution requires a reported reason');
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = { STATUSES, RESULT_KEYS, artifactPath, parseResult, reportCounts, frontmatter };
|
|
@@ -552,6 +552,8 @@ function resolveTeaConfig({ projectRoot, flags = {}, baseRef, skillRoot, skillNa
|
|
|
552
552
|
module.exports = {
|
|
553
553
|
resolveTeaConfig,
|
|
554
554
|
readTeaConfigFile,
|
|
555
|
+
readWorkflowCustomization,
|
|
556
|
+
configSource,
|
|
555
557
|
isPackageInstalled,
|
|
556
558
|
KEY_TO_PACKAGE,
|
|
557
559
|
KEY_TO_INSTALLED_FIELD,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "bmad-method-test-architecture-enterprise",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.4-next.0",
|
|
5
5
|
"description": "Master Test Architect for quality strategy, test automation, and release gates",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"bmad",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"bin": {
|
|
34
34
|
"tea-atdd-red-check": "cli/atdd-red-check.js",
|
|
35
35
|
"tea-atdd-runner": "cli/atdd-runner.js",
|
|
36
|
+
"tea-automate": "cli/automate.js",
|
|
36
37
|
"tea-ci-runner": "cli/ci-runner.js",
|
|
37
38
|
"tea-evaluate": "cli/evaluate.js",
|
|
38
39
|
"tea-fragment-selection-runner": "cli/fragment-selection-runner.js",
|
|
@@ -89,12 +90,14 @@
|
|
|
89
90
|
"release:minor": "gh workflow run publish.yaml -f channel=latest -f bump=minor",
|
|
90
91
|
"release:next": "gh workflow run publish.yaml -f channel=next",
|
|
91
92
|
"release:patch": "gh workflow run publish.yaml -f channel=latest -f bump=patch",
|
|
92
|
-
"test": "npm run test:conflict-markers && npm run test:schemas && npm run test:install && npm run test:evaluate-guidance && npm run test:evaluate-authoring && npm run test:evaluate-gap-loop && npm run test:evaluate-dogfood && npm run test:evaluate-check && npm run test:evaluate-compare && npm run test:evaluate-before-state && npm run test:evaluate-ci-gates && npm run test:evaluate-ci-tiers && npm run test:evaluate-ci-repositories:tagged-release-pr && npm run test:evaluate-ci-repositories:tagged-release-merge && npm run test:evaluate-ci-repositories:tagged-release-release && npm run test:evaluate-ci-repositories:nightly-deploy-pr && npm run test:evaluate-ci-repositories:nightly-deploy-merge && npm run test:evaluate-ci-repositories:nightly-deploy-scheduled && npm run test:evaluate-ci-repositories:nightly-deploy-release && npm run test:evaluate-ci-render && npm run test:evaluate-pr-mcp && npm run test:evaluate-pr-api && npm run test:evaluate-pr-suite && npm run test:evaluate-pr-workflow && npm run test:evaluate-pr-tool-use && npm run test:evaluate-pr-promptfoo && npm run test:evaluate-pr-ai-feature && npm run test:evaluate-pr-test-review && npm run test:evaluate-pr-gap-loop && npm run test:evaluate-pr-learn && npm run test:evaluate-pr-tutorial && npm run test:evaluate-partitions && npm run test:evaluate-partition-plans-views-and-check && npm run test:evaluate-partition-plans-preflight-and-run && npm run test:evaluate-partition-plans-score && npm run test:evaluate-partition-plans-attempts-seal-and-mappings && npm run test:evaluate-partition-plans-attempts-records && npm run test:evaluate-partition-plans-attempts-gameability && npm run test:evaluate-interpret && npm run test:evaluate-calibration && npm run test:evaluate-boundaries && npm run test:evaluate-preflight && npm run test:evaluate-isolation-conformance && npm run test:evaluate-mutation:qualification-basics && npm run test:evaluate-mutation:qualification-guards && npm run test:evaluate-mutation:recovery && npm run test:evaluate-mutation:journals && npm run test:evaluate-run && npm run test:evaluate-progress && npm run test:evaluate-aggregate && npm run test:evaluate-confinement && npm run test:evaluate-confinement-audit && npm run test:evaluate-confinement-history && npm run test:evaluate-confinement-git-state && npm run test:evaluate-confinement-network && npm run test:evaluate-held-inputs && npm run test:evaluate-arms-historical && npm run test:evaluate-arms-deployments && npm run test:evaluate-evaluators && npm run test:evaluate-evaluators-agent-version && npm run test:evaluate-agents && npm run test:evaluate-agents-importers && npm run test:evaluate-agents-sealed-brief && npm run test:evaluate-held-attempts && npm run test:evaluate-private && npm run test:evaluate-records && npm run test:evaluate-mcp && npm run test:evaluate-api && npm run test:evaluate-workflow && npm run test:evaluate-tool-use && npm run test:evaluate-promptfoo && npm run test:evaluate-learned-framework && npm run test:docs-tutorial && npm run test:knowledge && npm run test:criteria-fragments && npm run test:doc-count-sources && npm run test:planning-doc-sources && npm run test:doc-counts && npm run test:doc-claim-sources && npm run test:doc-claims && npm run test:doc-invocation-entry && npm run test:doc-invocations && npm run test:doc-link-anchors && npm run test:docs-build-names && npm run test:docs-evaluate-sidebar && npm run test:docs-gap-reader && npm run test:enforce-hook && npm run test:framework-scaffold && npm run test:isolation-primitives && npm run test:atdd-isolation && npm run test:atdd-net-guard && npm run test:atdd-scoring && npm run test:atdd-workflow-guidance && npm run test:generation-healing && npm run test:framework-scaffold-install-isolation && npm run test:transcript-harness && npm run test:suite-manifest && npm run test:teach-me-testing-scoring && npm run test:eval-data && npm run test:eval-atdd-data && npm run test:eval-ci-data && npm run test:eval-framework-scaffold-data && npm run test:eval-nfr-data && npm run test:nfr-validate-corpus && npm run test:nfr-evidence-guidance && npm run test:eval-test-design-data && npm run test:eval-trace-data && npm run test:eval-routing-data && npm run test:eval-routing-boundaries && npm run test:eval-routing-evidence && npm run test:eval-transcript-data && npm run test:eval-teach-me-testing-data && npm run test:eval-schemas && npm run test:eval-diagnostics && npm run test:eval-replay && npm run test:eval-quality-corpus && npm run test:schema-versions && npm run test:contract-sources && npm run test:contracts && npm run test:contract-oracles && npm run test:probe-sources && npm run test:test-design-qualification && npm run test:test-review-qualification && npm run test:trace-qualification && npm run test:nfr-qualification && npm run test:ci-qualification && npm run test:probe-corpus && npm run test:compare-dominance && npm run test:compare-eval-runs && npm run test:trial-set-scoring && npm run test:automate-eval-fixture && npm run test:eval-automate-data && npm run test:clock-port && npm run test:probe-conformance && npm run test:corpus-conformance && npm run test:file-system-conformance && npm run test:file-system-port && npm run test:probe-targets && npm run test:port-totality && npm run test:ci-coverage && npm run test:ci-coverage-filters && npm run test:install-actionlint && npm run test:workflows-lint && npm run test:install-linux-sandbox && npm run test:shards && npm run test:groups && npm run test:cli && npm run test:test-review-pr-scope && npm run test:test-review-report && npm run test:test-review-rubric && npm run test:bmad-output-gated && npm run test:release-metadata && npm run test:release-notes && npm run test:lockfile-age && npm run test:lockfile-age-cache && npm run test:licences && npm run test:supply-chain && npm run test:boundary && npm run test:direction && npm run test:lineage && npm run test:guard-publish && npm run test:layering-boundary-lineage && npm run test:changelog && npm run test:tea-workflow-descriptions && npm run test:docs-mermaid && npm run test:llms-headroom && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check",
|
|
93
|
+
"test": "npm run test:conflict-markers && npm run test:schemas && npm run test:install && npm run test:evaluate-guidance && npm run test:evaluate-authoring && npm run test:evaluate-gap-loop && npm run test:evaluate-dogfood && npm run test:evaluate-check && npm run test:evaluate-compare && npm run test:evaluate-before-state && npm run test:evaluate-ci-gates && npm run test:evaluate-ci-tiers && npm run test:evaluate-ci-repositories:tagged-release-pr && npm run test:evaluate-ci-repositories:tagged-release-merge && npm run test:evaluate-ci-repositories:tagged-release-release && npm run test:evaluate-ci-repositories:nightly-deploy-pr && npm run test:evaluate-ci-repositories:nightly-deploy-merge && npm run test:evaluate-ci-repositories:nightly-deploy-scheduled && npm run test:evaluate-ci-repositories:nightly-deploy-release && npm run test:evaluate-ci-render && npm run test:evaluate-pr-mcp && npm run test:evaluate-pr-api && npm run test:evaluate-pr-suite && npm run test:evaluate-pr-workflow && npm run test:evaluate-pr-tool-use && npm run test:evaluate-pr-promptfoo && npm run test:evaluate-pr-ai-feature && npm run test:evaluate-pr-test-review && npm run test:evaluate-pr-gap-loop && npm run test:evaluate-pr-learn && npm run test:evaluate-pr-tutorial && npm run test:evaluate-partitions && npm run test:evaluate-partition-plans-views-and-check && npm run test:evaluate-partition-plans-preflight-and-run && npm run test:evaluate-partition-plans-score && npm run test:evaluate-partition-plans-attempts-seal-and-mappings && npm run test:evaluate-partition-plans-attempts-records && npm run test:evaluate-partition-plans-attempts-gameability && npm run test:evaluate-interpret && npm run test:evaluate-calibration && npm run test:evaluate-boundaries && npm run test:evaluate-preflight && npm run test:evaluate-isolation-conformance && npm run test:evaluate-mutation:qualification-basics && npm run test:evaluate-mutation:qualification-guards && npm run test:evaluate-mutation:recovery && npm run test:evaluate-mutation:journals && npm run test:evaluate-run && npm run test:evaluate-progress && npm run test:evaluate-aggregate && npm run test:evaluate-confinement && npm run test:evaluate-confinement-audit && npm run test:evaluate-confinement-history && npm run test:evaluate-confinement-git-state && npm run test:evaluate-confinement-network && npm run test:evaluate-held-inputs && npm run test:evaluate-arms-historical && npm run test:evaluate-arms-deployments && npm run test:evaluate-evaluators && npm run test:evaluate-evaluators-agent-version && npm run test:evaluate-agents && npm run test:evaluate-agents-importers && npm run test:evaluate-agents-sealed-brief && npm run test:evaluate-held-attempts && npm run test:evaluate-private && npm run test:evaluate-records && npm run test:evaluate-mcp && npm run test:evaluate-api && npm run test:evaluate-workflow && npm run test:evaluate-tool-use && npm run test:evaluate-promptfoo && npm run test:evaluate-learned-framework && npm run test:docs-tutorial && npm run test:knowledge && npm run test:criteria-fragments && npm run test:doc-count-sources && npm run test:planning-doc-sources && npm run test:doc-counts && npm run test:doc-claim-sources && npm run test:doc-claims && npm run test:doc-invocation-entry && npm run test:doc-invocations && npm run test:doc-link-anchors && npm run test:docs-build-names && npm run test:docs-evaluate-sidebar && npm run test:docs-gap-reader && npm run test:enforce-hook && npm run test:framework-scaffold && npm run test:isolation-primitives && npm run test:atdd-isolation && npm run test:atdd-net-guard && npm run test:atdd-scoring && npm run test:atdd-workflow-guidance && npm run test:generation-healing && npm run test:framework-scaffold-install-isolation && npm run test:transcript-harness && npm run test:suite-manifest && npm run test:teach-me-testing-scoring && npm run test:eval-data && npm run test:eval-atdd-data && npm run test:eval-ci-data && npm run test:eval-framework-scaffold-data && npm run test:eval-nfr-data && npm run test:nfr-validate-corpus && npm run test:nfr-evidence-guidance && npm run test:eval-test-design-data && npm run test:eval-trace-data && npm run test:eval-routing-data && npm run test:eval-routing-boundaries && npm run test:eval-routing-evidence && npm run test:eval-transcript-data && npm run test:eval-teach-me-testing-data && npm run test:eval-schemas && npm run test:eval-diagnostics && npm run test:eval-replay && npm run test:eval-quality-corpus && npm run test:schema-versions && npm run test:contract-sources && npm run test:contracts && npm run test:contract-oracles && npm run test:probe-sources && npm run test:test-design-qualification && npm run test:test-review-qualification && npm run test:trace-qualification && npm run test:nfr-qualification && npm run test:ci-qualification && npm run test:probe-corpus && npm run test:compare-dominance && npm run test:compare-eval-runs && npm run test:trial-set-scoring && npm run test:automate-cli && npm run test:automate-generation-evidence && npm run test:automate-eval-fixture && npm run test:eval-automate-data && npm run test:clock-port && npm run test:probe-conformance && npm run test:corpus-conformance && npm run test:file-system-conformance && npm run test:file-system-port && npm run test:probe-targets && npm run test:port-totality && npm run test:ci-coverage && npm run test:ci-coverage-filters && npm run test:install-actionlint && npm run test:workflows-lint && npm run test:install-linux-sandbox && npm run test:shards && npm run test:groups && npm run test:cli && npm run test:test-review-pr-scope && npm run test:test-review-report && npm run test:test-review-rubric && npm run test:bmad-output-gated && npm run test:release-metadata && npm run test:release-notes && npm run test:lockfile-age && npm run test:lockfile-age-cache && npm run test:licences && npm run test:supply-chain && npm run test:boundary && npm run test:direction && npm run test:lineage && npm run test:guard-publish && npm run test:layering-boundary-lineage && npm run test:changelog && npm run test:tea-workflow-descriptions && npm run test:docs-mermaid && npm run test:llms-headroom && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check",
|
|
93
94
|
"test:atdd-isolation": "node test/test-atdd-isolation.js",
|
|
94
95
|
"test:atdd-net-guard": "node test/test-atdd-net-guard.js",
|
|
95
96
|
"test:atdd-scoring": "node test/test-atdd-scoring.js",
|
|
96
97
|
"test:atdd-workflow-guidance": "node test/test-atdd-workflow-guidance.js",
|
|
98
|
+
"test:automate-cli": "node test/test-automate-cli.js",
|
|
97
99
|
"test:automate-eval-fixture": "node test/test-automate-eval-fixture.js",
|
|
100
|
+
"test:automate-generation-evidence": "node test/test-automate-generation-evidence.js",
|
|
98
101
|
"test:bmad-output-gated": "node test/test-bmad-output-gated.js",
|
|
99
102
|
"test:boundary": "eval-quality-gates package-boundary",
|
|
100
103
|
"test:changelog": "node test/test-stamp-changelog.js",
|
|
@@ -304,6 +307,7 @@
|
|
|
304
307
|
"statements": 78
|
|
305
308
|
},
|
|
306
309
|
"dependencies": {
|
|
310
|
+
"@babel/parser": "^7.29.9",
|
|
307
311
|
"@clack/prompts": "^0.11.0",
|
|
308
312
|
"ajv": "^8.20.0",
|
|
309
313
|
"boxen": "^5.1.2",
|
|
@@ -32,6 +32,7 @@ This is an **isolated subagent** running in parallel with E2E red-phase test gen
|
|
|
32
32
|
- 📖 Read this entire subagent file before acting
|
|
33
33
|
- ✅ Generate red-phase API test scaffolds ONLY
|
|
34
34
|
- ✅ Tests MUST be emitted with `test.skip()` until the developer activates them
|
|
35
|
+
- ✅ Register each Playwright scaffold with a direct `test.skip` call and a literal criterion title. Literal `test.describe` suites may contain those calls. Avoid loops, `test.each()`, helper registrations, computed titles, and template titles; the CLI verifies each generated leaf against runner evidence.
|
|
35
36
|
- ✅ Output structured JSON to temp file
|
|
36
37
|
- ✅ Follow knowledge fragment patterns
|
|
37
38
|
- ❌ Do NOT generate E2E tests (that's subagent 4B)
|
|
@@ -29,6 +29,7 @@ This is an **isolated subagent** running in parallel with API red-phase test gen
|
|
|
29
29
|
- 📖 Read this entire subagent file before acting
|
|
30
30
|
- ✅ Generate red-phase E2E test scaffolds ONLY
|
|
31
31
|
- ✅ Tests MUST be emitted with `test.skip()` until the developer activates them
|
|
32
|
+
- ✅ Register each Playwright scaffold with a direct `test.skip` call and a literal criterion title. Literal `test.describe` suites may contain those calls. Avoid loops, `test.each()`, helper registrations, computed titles, and template titles; the CLI verifies each generated leaf against runner evidence.
|
|
32
33
|
- ✅ Output structured JSON to temp file
|
|
33
34
|
- ✅ Follow knowledge fragment patterns
|
|
34
35
|
- ❌ Do NOT generate API tests (that's subagent 4A)
|
|
@@ -40,6 +40,7 @@ Gate broker calls on `pact_mcp_reachable`, not on `pact_mcp`. The mode says the
|
|
|
40
40
|
|
|
41
41
|
- 📖 Read this entire subagent file before acting
|
|
42
42
|
- ✅ Generate API tests ONLY
|
|
43
|
+
- ✅ Register each Playwright test with a direct `test('literal title', ...)` call. Literal `test.describe('literal title', ...)` suites may contain those calls. Do not register tests through loops, `test.each()`, helper functions, computed titles, or template titles; the CLI cannot verify those dynamic registrations against runner evidence.
|
|
43
44
|
- ✅ Output structured JSON to temp file
|
|
44
45
|
- ✅ Follow knowledge fragment patterns
|
|
45
46
|
- ❌ Do NOT generate E2E tests (that's subagent 3B)
|
|
@@ -28,6 +28,7 @@ This is an **isolated subagent** running in parallel with API test generation.
|
|
|
28
28
|
|
|
29
29
|
- 📖 Read this entire subagent file before acting
|
|
30
30
|
- ✅ Generate E2E tests ONLY
|
|
31
|
+
- ✅ Register each Playwright test with a direct `test('literal title', ...)` call. Literal `test.describe('literal title', ...)` suites may contain those calls. Do not register tests through loops, `test.each()`, helper functions, computed titles, or template titles; the CLI cannot verify those dynamic registrations against runner evidence.
|
|
31
32
|
- ✅ Output structured JSON to temp file
|
|
32
33
|
- ✅ Follow knowledge fragment patterns
|
|
33
34
|
- ❌ Do NOT generate API tests (that's subagent 3A)
|