bmad-fh 6.0.0-alpha.23 → 6.0.0-alpha.23.3b00cb36
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/.github/workflows/publish.yaml +68 -0
- package/.husky/pre-commit +17 -2
- package/.husky/pre-push +10 -0
- package/README.md +117 -14
- package/eslint.config.mjs +2 -2
- package/package.json +1 -2
- package/src/bmm/module.yaml +2 -2
- package/src/core/lib/scope/artifact-resolver.js +26 -26
- package/src/core/lib/scope/event-logger.js +34 -45
- package/src/core/lib/scope/index.js +3 -3
- package/src/core/lib/scope/scope-context.js +22 -28
- package/src/core/lib/scope/scope-initializer.js +29 -31
- package/src/core/lib/scope/scope-manager.js +57 -24
- package/src/core/lib/scope/scope-migrator.js +44 -52
- package/src/core/lib/scope/scope-sync.js +42 -48
- package/src/core/lib/scope/scope-validator.js +16 -21
- package/src/core/lib/scope/state-lock.js +37 -43
- package/src/core/module.yaml +2 -2
- package/test/test-scope-cli.js +1306 -0
- package/test/test-scope-e2e.js +682 -92
- package/test/test-scope-system.js +973 -169
- package/tools/cli/bmad-cli.js +5 -0
- package/tools/cli/commands/scope.js +1250 -115
- package/tools/cli/installers/lib/modules/manager.js +6 -2
- package/tools/cli/scripts/migrate-workflows.js +43 -51
- package/.github/workflows/publish-multi-artifact.yaml +0 -50
package/test/test-scope-e2e.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* End-to-End Test: Multi-Scope Parallel Workflows
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Tests the complete flow of running parallel workflows in different scopes,
|
|
5
5
|
* including artifact isolation, sync operations, and event notifications.
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
7
|
* Usage: node test/test-scope-e2e.js
|
|
8
8
|
* Exit codes: 0 = all tests pass, 1 = test failures
|
|
9
9
|
*/
|
|
@@ -97,12 +97,12 @@ function assertFileContains(filePath, content, message = '') {
|
|
|
97
97
|
// Create temporary test directory with BMAD structure
|
|
98
98
|
function createTestProject() {
|
|
99
99
|
const tmpDir = path.join(os.tmpdir(), `bmad-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
100
|
-
|
|
100
|
+
|
|
101
101
|
// Create BMAD directory structure
|
|
102
102
|
fs.mkdirSync(path.join(tmpDir, '_bmad', '_config'), { recursive: true });
|
|
103
103
|
fs.mkdirSync(path.join(tmpDir, '_bmad', '_events'), { recursive: true });
|
|
104
104
|
fs.mkdirSync(path.join(tmpDir, '_bmad-output'), { recursive: true });
|
|
105
|
-
|
|
105
|
+
|
|
106
106
|
return tmpDir;
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -116,33 +116,33 @@ function cleanupTestProject(tmpDir) {
|
|
|
116
116
|
|
|
117
117
|
async function testParallelScopeWorkflow() {
|
|
118
118
|
console.log(`\n${colors.blue}E2E: Parallel Scope Workflow Simulation${colors.reset}`);
|
|
119
|
-
|
|
119
|
+
|
|
120
120
|
const { ScopeManager } = require('../src/core/lib/scope/scope-manager');
|
|
121
121
|
const { ScopeContext } = require('../src/core/lib/scope/scope-context');
|
|
122
122
|
const { ArtifactResolver } = require('../src/core/lib/scope/artifact-resolver');
|
|
123
123
|
const { ScopeSync } = require('../src/core/lib/scope/scope-sync');
|
|
124
124
|
const { EventLogger } = require('../src/core/lib/scope/event-logger');
|
|
125
|
-
|
|
125
|
+
|
|
126
126
|
let tmpDir;
|
|
127
|
-
|
|
127
|
+
|
|
128
128
|
try {
|
|
129
129
|
tmpDir = createTestProject();
|
|
130
|
-
|
|
130
|
+
|
|
131
131
|
// Initialize components
|
|
132
132
|
const manager = new ScopeManager({ projectRoot: tmpDir });
|
|
133
133
|
const context = new ScopeContext({ projectRoot: tmpDir });
|
|
134
|
-
|
|
134
|
+
|
|
135
135
|
// ========================================
|
|
136
136
|
// Step 1: Initialize scope system
|
|
137
137
|
// ========================================
|
|
138
138
|
await asyncTest('Initialize scope system', async () => {
|
|
139
139
|
await manager.initialize();
|
|
140
|
-
|
|
140
|
+
|
|
141
141
|
assertFileExists(path.join(tmpDir, '_bmad', '_config', 'scopes.yaml'));
|
|
142
142
|
assertFileExists(path.join(tmpDir, '_bmad-output', '_shared'));
|
|
143
143
|
assertFileExists(path.join(tmpDir, '_bmad', '_events'));
|
|
144
144
|
});
|
|
145
|
-
|
|
145
|
+
|
|
146
146
|
// ========================================
|
|
147
147
|
// Step 2: Create two scopes (auth and payments)
|
|
148
148
|
// ========================================
|
|
@@ -151,25 +151,25 @@ async function testParallelScopeWorkflow() {
|
|
|
151
151
|
name: 'Authentication Service',
|
|
152
152
|
description: 'User auth, SSO, authorization',
|
|
153
153
|
});
|
|
154
|
-
|
|
154
|
+
|
|
155
155
|
assertEqual(scope.id, 'auth');
|
|
156
156
|
assertEqual(scope.status, 'active');
|
|
157
157
|
assertFileExists(path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts'));
|
|
158
158
|
assertFileExists(path.join(tmpDir, '_bmad-output', 'auth', 'implementation-artifacts'));
|
|
159
159
|
assertFileExists(path.join(tmpDir, '_bmad-output', 'auth', 'tests'));
|
|
160
160
|
});
|
|
161
|
-
|
|
161
|
+
|
|
162
162
|
await asyncTest('Create payments scope with dependency on auth', async () => {
|
|
163
163
|
const scope = await manager.createScope('payments', {
|
|
164
164
|
name: 'Payment Processing',
|
|
165
165
|
description: 'Payment gateway integration',
|
|
166
166
|
dependencies: ['auth'],
|
|
167
167
|
});
|
|
168
|
-
|
|
168
|
+
|
|
169
169
|
assertEqual(scope.id, 'payments');
|
|
170
170
|
assertTrue(scope.dependencies.includes('auth'));
|
|
171
171
|
});
|
|
172
|
-
|
|
172
|
+
|
|
173
173
|
// ========================================
|
|
174
174
|
// Step 3: Simulate parallel artifact creation
|
|
175
175
|
// ========================================
|
|
@@ -177,39 +177,33 @@ async function testParallelScopeWorkflow() {
|
|
|
177
177
|
// Create PRD in auth scope
|
|
178
178
|
const prdPath = path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts', 'prd.md');
|
|
179
179
|
fs.writeFileSync(prdPath, '# Auth PRD\n\nAuthentication requirements...');
|
|
180
|
-
|
|
180
|
+
|
|
181
181
|
assertFileExists(prdPath);
|
|
182
182
|
assertFileContains(prdPath, 'Auth PRD');
|
|
183
183
|
});
|
|
184
|
-
|
|
184
|
+
|
|
185
185
|
await asyncTest('Simulate parallel PRD creation in payments scope', async () => {
|
|
186
186
|
// Create PRD in payments scope
|
|
187
187
|
const prdPath = path.join(tmpDir, '_bmad-output', 'payments', 'planning-artifacts', 'prd.md');
|
|
188
188
|
fs.writeFileSync(prdPath, '# Payments PRD\n\nPayment processing requirements...');
|
|
189
|
-
|
|
189
|
+
|
|
190
190
|
assertFileExists(prdPath);
|
|
191
191
|
assertFileContains(prdPath, 'Payments PRD');
|
|
192
192
|
});
|
|
193
|
-
|
|
193
|
+
|
|
194
194
|
// ========================================
|
|
195
195
|
// Step 4: Verify artifact isolation
|
|
196
196
|
// ========================================
|
|
197
197
|
await asyncTest('Verify artifacts are isolated', async () => {
|
|
198
|
-
const authPrd = fs.readFileSync(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
);
|
|
202
|
-
const paymentsPrd = fs.readFileSync(
|
|
203
|
-
path.join(tmpDir, '_bmad-output', 'payments', 'planning-artifacts', 'prd.md'),
|
|
204
|
-
'utf8'
|
|
205
|
-
);
|
|
206
|
-
|
|
198
|
+
const authPrd = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts', 'prd.md'), 'utf8');
|
|
199
|
+
const paymentsPrd = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'payments', 'planning-artifacts', 'prd.md'), 'utf8');
|
|
200
|
+
|
|
207
201
|
assertTrue(authPrd.includes('Auth PRD'), 'Auth PRD should contain auth content');
|
|
208
202
|
assertTrue(paymentsPrd.includes('Payments PRD'), 'Payments PRD should contain payments content');
|
|
209
203
|
assertFalse(authPrd.includes('Payments'), 'Auth PRD should not contain payments content');
|
|
210
204
|
assertFalse(paymentsPrd.includes('Auth'), 'Payments PRD should not contain auth content');
|
|
211
205
|
});
|
|
212
|
-
|
|
206
|
+
|
|
213
207
|
// ========================================
|
|
214
208
|
// Step 5: Test ArtifactResolver access control
|
|
215
209
|
// ========================================
|
|
@@ -219,14 +213,14 @@ async function testParallelScopeWorkflow() {
|
|
|
219
213
|
basePath: '_bmad-output',
|
|
220
214
|
projectRoot: tmpDir,
|
|
221
215
|
});
|
|
222
|
-
|
|
223
|
-
// Payments scope can read auth scope
|
|
216
|
+
|
|
217
|
+
// Payments scope can read auth scope - canRead returns {allowed, reason}
|
|
224
218
|
assertTrue(
|
|
225
|
-
resolver.canRead(path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts', 'prd.md')),
|
|
226
|
-
'Should allow cross-scope read'
|
|
219
|
+
resolver.canRead(path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts', 'prd.md')).allowed,
|
|
220
|
+
'Should allow cross-scope read',
|
|
227
221
|
);
|
|
228
222
|
});
|
|
229
|
-
|
|
223
|
+
|
|
230
224
|
await asyncTest('ArtifactResolver blocks cross-scope write', async () => {
|
|
231
225
|
const resolver = new ArtifactResolver({
|
|
232
226
|
currentScope: 'payments',
|
|
@@ -234,111 +228,106 @@ async function testParallelScopeWorkflow() {
|
|
|
234
228
|
projectRoot: tmpDir,
|
|
235
229
|
isolationMode: 'strict',
|
|
236
230
|
});
|
|
237
|
-
|
|
238
|
-
// Payments scope cannot write to auth scope
|
|
231
|
+
|
|
232
|
+
// Payments scope cannot write to auth scope - canWrite returns {allowed, reason, warning}
|
|
239
233
|
assertFalse(
|
|
240
|
-
resolver.canWrite(path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts', 'new.md')),
|
|
241
|
-
'Should block cross-scope write'
|
|
234
|
+
resolver.canWrite(path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts', 'new.md')).allowed,
|
|
235
|
+
'Should block cross-scope write',
|
|
242
236
|
);
|
|
243
237
|
});
|
|
244
|
-
|
|
238
|
+
|
|
245
239
|
// ========================================
|
|
246
240
|
// Step 6: Test scope context session
|
|
247
241
|
// ========================================
|
|
248
242
|
await asyncTest('Session-sticky scope works', async () => {
|
|
249
243
|
await context.setScope('auth');
|
|
250
|
-
|
|
244
|
+
|
|
251
245
|
const currentScope = await context.getCurrentScope();
|
|
252
246
|
assertEqual(currentScope, 'auth', 'Session scope should be auth');
|
|
253
|
-
|
|
247
|
+
|
|
254
248
|
// Check .bmad-scope file was created
|
|
255
249
|
assertFileExists(path.join(tmpDir, '.bmad-scope'));
|
|
256
250
|
});
|
|
257
|
-
|
|
251
|
+
|
|
258
252
|
await asyncTest('Session scope can be switched', async () => {
|
|
259
253
|
await context.setScope('payments');
|
|
260
|
-
|
|
254
|
+
|
|
261
255
|
const currentScope = await context.getCurrentScope();
|
|
262
256
|
assertEqual(currentScope, 'payments', 'Session scope should be payments');
|
|
263
257
|
});
|
|
264
|
-
|
|
258
|
+
|
|
265
259
|
// ========================================
|
|
266
260
|
// Step 7: Test sync-up (promote to shared)
|
|
267
261
|
// ========================================
|
|
268
262
|
await asyncTest('Sync-up promotes artifacts to shared layer', async () => {
|
|
269
263
|
const sync = new ScopeSync({ projectRoot: tmpDir });
|
|
270
|
-
|
|
271
|
-
// Create a promotable artifact
|
|
272
|
-
const archPath = path.join(tmpDir, '_bmad-output', 'auth', '
|
|
264
|
+
|
|
265
|
+
// Create a promotable artifact matching pattern 'architecture/*.md'
|
|
266
|
+
const archPath = path.join(tmpDir, '_bmad-output', 'auth', 'architecture', 'overview.md');
|
|
273
267
|
fs.mkdirSync(path.dirname(archPath), { recursive: true });
|
|
274
268
|
fs.writeFileSync(archPath, '# Auth Architecture\n\nShared auth patterns...');
|
|
275
|
-
|
|
276
|
-
// Create architecture directory in shared
|
|
277
|
-
fs.mkdirSync(path.join(tmpDir, '_bmad-output', '_shared', 'auth'), { recursive: true });
|
|
278
|
-
|
|
269
|
+
|
|
279
270
|
await sync.syncUp('auth');
|
|
280
|
-
|
|
281
|
-
// Check artifact was promoted
|
|
271
|
+
|
|
272
|
+
// Check artifact was promoted to _shared/auth/architecture/overview.md
|
|
282
273
|
assertFileExists(
|
|
283
|
-
path.join(tmpDir, '_bmad-output', '_shared', 'auth', 'architecture.md'),
|
|
284
|
-
'Architecture should be promoted to shared'
|
|
274
|
+
path.join(tmpDir, '_bmad-output', '_shared', 'auth', 'architecture', 'overview.md'),
|
|
275
|
+
'Architecture should be promoted to shared',
|
|
285
276
|
);
|
|
286
277
|
});
|
|
287
|
-
|
|
278
|
+
|
|
288
279
|
// ========================================
|
|
289
280
|
// Step 8: Test event logging
|
|
290
281
|
// ========================================
|
|
291
282
|
await asyncTest('Events are logged', async () => {
|
|
292
283
|
const eventLogger = new EventLogger({ projectRoot: tmpDir });
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
const events = await eventLogger.getEvents({ scope: 'auth' });
|
|
284
|
+
await eventLogger.initialize();
|
|
285
|
+
|
|
286
|
+
// EventLogger uses logEvent(type, scopeId, data) not log({...})
|
|
287
|
+
await eventLogger.logEvent('artifact_created', 'auth', { artifact: 'prd.md' });
|
|
288
|
+
|
|
289
|
+
// getEvents takes (scopeId, options) not ({scope})
|
|
290
|
+
const events = await eventLogger.getEvents('auth');
|
|
301
291
|
assertTrue(events.length > 0, 'Should have logged events');
|
|
302
292
|
assertEqual(events[0].type, 'artifact_created');
|
|
303
293
|
});
|
|
304
|
-
|
|
294
|
+
|
|
305
295
|
// ========================================
|
|
306
296
|
// Step 9: Test dependency tracking
|
|
307
297
|
// ========================================
|
|
308
298
|
await asyncTest('Dependent scopes can be found', async () => {
|
|
309
299
|
const dependents = await manager.findDependentScopes('auth');
|
|
310
|
-
|
|
300
|
+
|
|
311
301
|
assertTrue(dependents.includes('payments'), 'payments should depend on auth');
|
|
312
302
|
});
|
|
313
|
-
|
|
303
|
+
|
|
314
304
|
// ========================================
|
|
315
305
|
// Step 10: Test scope archival
|
|
316
306
|
// ========================================
|
|
317
307
|
await asyncTest('Scope can be archived', async () => {
|
|
318
308
|
await manager.archiveScope('auth');
|
|
319
|
-
|
|
309
|
+
|
|
320
310
|
const scope = await manager.getScope('auth');
|
|
321
311
|
assertEqual(scope.status, 'archived', 'Scope should be archived');
|
|
322
|
-
|
|
312
|
+
|
|
323
313
|
// Re-activate for cleanup
|
|
324
314
|
await manager.activateScope('auth');
|
|
325
315
|
});
|
|
326
|
-
|
|
316
|
+
|
|
327
317
|
// ========================================
|
|
328
318
|
// Step 11: Verify final state
|
|
329
319
|
// ========================================
|
|
330
320
|
await asyncTest('Final state verification', async () => {
|
|
331
321
|
const scopes = await manager.listScopes();
|
|
332
322
|
assertEqual(scopes.length, 2, 'Should have 2 scopes');
|
|
333
|
-
|
|
323
|
+
|
|
334
324
|
// Both scopes should have their artifacts
|
|
335
325
|
assertFileExists(path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts', 'prd.md'));
|
|
336
326
|
assertFileExists(path.join(tmpDir, '_bmad-output', 'payments', 'planning-artifacts', 'prd.md'));
|
|
337
|
-
|
|
327
|
+
|
|
338
328
|
// Shared layer should have promoted artifacts
|
|
339
329
|
assertFileExists(path.join(tmpDir, '_bmad-output', '_shared'));
|
|
340
330
|
});
|
|
341
|
-
|
|
342
331
|
} finally {
|
|
343
332
|
if (tmpDir) {
|
|
344
333
|
cleanupTestProject(tmpDir);
|
|
@@ -352,52 +341,645 @@ async function testParallelScopeWorkflow() {
|
|
|
352
341
|
|
|
353
342
|
async function testConcurrentLockSimulation() {
|
|
354
343
|
console.log(`\n${colors.blue}E2E: Concurrent Lock Simulation${colors.reset}`);
|
|
355
|
-
|
|
344
|
+
|
|
356
345
|
const { StateLock } = require('../src/core/lib/scope/state-lock');
|
|
357
|
-
|
|
346
|
+
|
|
358
347
|
let tmpDir;
|
|
359
|
-
|
|
348
|
+
|
|
360
349
|
try {
|
|
361
350
|
tmpDir = createTestProject();
|
|
362
351
|
const lock = new StateLock();
|
|
363
352
|
const lockPath = path.join(tmpDir, 'state.lock');
|
|
364
|
-
|
|
353
|
+
|
|
365
354
|
// ========================================
|
|
366
355
|
// Simulate concurrent access from two "terminals"
|
|
367
356
|
// ========================================
|
|
368
357
|
await asyncTest('Concurrent operations are serialized', async () => {
|
|
369
358
|
const results = [];
|
|
370
359
|
const startTime = Date.now();
|
|
371
|
-
|
|
360
|
+
|
|
372
361
|
// Simulate Terminal 1 (auth scope)
|
|
373
362
|
const terminal1 = lock.withLock(lockPath, async () => {
|
|
374
363
|
results.push({ terminal: 1, action: 'start', time: Date.now() - startTime });
|
|
375
|
-
await new Promise(r => setTimeout(r, 50)); // Simulate work
|
|
364
|
+
await new Promise((r) => setTimeout(r, 50)); // Simulate work
|
|
376
365
|
results.push({ terminal: 1, action: 'end', time: Date.now() - startTime });
|
|
377
366
|
return 'terminal1';
|
|
378
367
|
});
|
|
379
|
-
|
|
368
|
+
|
|
380
369
|
// Simulate Terminal 2 (payments scope) - starts slightly after
|
|
381
|
-
await new Promise(r => setTimeout(r, 10));
|
|
370
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
382
371
|
const terminal2 = lock.withLock(lockPath, async () => {
|
|
383
372
|
results.push({ terminal: 2, action: 'start', time: Date.now() - startTime });
|
|
384
|
-
await new Promise(r => setTimeout(r, 50)); // Simulate work
|
|
373
|
+
await new Promise((r) => setTimeout(r, 50)); // Simulate work
|
|
385
374
|
results.push({ terminal: 2, action: 'end', time: Date.now() - startTime });
|
|
386
375
|
return 'terminal2';
|
|
387
376
|
});
|
|
388
|
-
|
|
377
|
+
|
|
389
378
|
await Promise.all([terminal1, terminal2]);
|
|
390
|
-
|
|
379
|
+
|
|
391
380
|
// Terminal 2 should start after Terminal 1 ends
|
|
392
|
-
const t1End = results.find(r => r.terminal === 1 && r.action === 'end');
|
|
393
|
-
const t2Start = results.find(r => r.terminal === 2 && r.action === 'start');
|
|
394
|
-
|
|
395
|
-
assertTrue(
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
381
|
+
const t1End = results.find((r) => r.terminal === 1 && r.action === 'end');
|
|
382
|
+
const t2Start = results.find((r) => r.terminal === 2 && r.action === 'start');
|
|
383
|
+
|
|
384
|
+
assertTrue(t2Start.time >= t1End.time, `Terminal 2 should start (${t2Start.time}ms) after Terminal 1 ends (${t1End.time}ms)`);
|
|
385
|
+
});
|
|
386
|
+
} finally {
|
|
387
|
+
if (tmpDir) {
|
|
388
|
+
cleanupTestProject(tmpDir);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ============================================================================
|
|
394
|
+
// E2E Test: Help Commands
|
|
395
|
+
// ============================================================================
|
|
396
|
+
|
|
397
|
+
async function testHelpCommandsE2E() {
|
|
398
|
+
console.log(`\n${colors.blue}E2E: Help Commands${colors.reset}`);
|
|
399
|
+
|
|
400
|
+
const { execSync } = require('node:child_process');
|
|
401
|
+
const cliPath = path.join(__dirname, '..', 'tools', 'cli', 'bmad-cli.js');
|
|
402
|
+
|
|
403
|
+
await asyncTest('scope --help shows subcommands', () => {
|
|
404
|
+
const output = execSync(`node ${cliPath} scope --help`, { encoding: 'utf8' });
|
|
405
|
+
assertTrue(output.includes('SUBCOMMANDS'), 'Should show SUBCOMMANDS section');
|
|
406
|
+
assertTrue(output.includes('init'), 'Should mention init');
|
|
407
|
+
assertTrue(output.includes('create'), 'Should mention create');
|
|
408
|
+
assertTrue(output.includes('list'), 'Should mention list');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
await asyncTest('scope -h shows same as --help', () => {
|
|
412
|
+
const output = execSync(`node ${cliPath} scope -h`, { encoding: 'utf8' });
|
|
413
|
+
assertTrue(output.includes('SUBCOMMANDS'), 'Should show SUBCOMMANDS section');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
await asyncTest('scope help shows comprehensive documentation', () => {
|
|
417
|
+
const output = execSync(`node ${cliPath} scope help`, { encoding: 'utf8' });
|
|
418
|
+
assertTrue(output.includes('OVERVIEW'), 'Should show OVERVIEW section');
|
|
419
|
+
assertTrue(output.includes('COMMANDS'), 'Should show COMMANDS section');
|
|
420
|
+
assertTrue(output.includes('QUICK START'), 'Should show QUICK START section');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await asyncTest('scope help create shows detailed create help', () => {
|
|
424
|
+
const output = execSync(`node ${cliPath} scope help create`, { encoding: 'utf8' });
|
|
425
|
+
assertTrue(output.includes('bmad scope create'), 'Should show create command title');
|
|
426
|
+
assertTrue(output.includes('ARGUMENTS'), 'Should show ARGUMENTS section');
|
|
427
|
+
assertTrue(output.includes('OPTIONS'), 'Should show OPTIONS section');
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
await asyncTest('scope help init shows detailed init help', () => {
|
|
431
|
+
const output = execSync(`node ${cliPath} scope help init`, { encoding: 'utf8' });
|
|
432
|
+
assertTrue(output.includes('bmad scope init'), 'Should show init command title');
|
|
433
|
+
assertTrue(output.includes('DESCRIPTION'), 'Should show DESCRIPTION section');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
await asyncTest('scope help with invalid subcommand shows error', () => {
|
|
437
|
+
const output = execSync(`node ${cliPath} scope help invalidcommand`, { encoding: 'utf8' });
|
|
438
|
+
assertTrue(output.includes('Unknown command'), 'Should show unknown command error');
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await asyncTest('scope help works with aliases', () => {
|
|
442
|
+
const output = execSync(`node ${cliPath} scope help ls`, { encoding: 'utf8' });
|
|
443
|
+
assertTrue(output.includes('bmad scope list'), 'Should show list help for ls alias');
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ============================================================================
|
|
448
|
+
// E2E Test: Error Handling and Edge Cases
|
|
449
|
+
// ============================================================================
|
|
450
|
+
|
|
451
|
+
async function testErrorHandlingE2E() {
|
|
452
|
+
console.log(`\n${colors.blue}E2E: Error Handling and Edge Cases${colors.reset}`);
|
|
453
|
+
|
|
454
|
+
const { ScopeManager } = require('../src/core/lib/scope/scope-manager');
|
|
455
|
+
const { ScopeContext } = require('../src/core/lib/scope/scope-context');
|
|
456
|
+
const { ScopeSync } = require('../src/core/lib/scope/scope-sync');
|
|
457
|
+
|
|
458
|
+
let tmpDir;
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
tmpDir = createTestProject();
|
|
462
|
+
const manager = new ScopeManager({ projectRoot: tmpDir });
|
|
463
|
+
|
|
464
|
+
// ========================================
|
|
465
|
+
// Error: Operations on uninitialized system
|
|
466
|
+
// ========================================
|
|
467
|
+
await asyncTest('List scopes on uninitialized system returns empty', async () => {
|
|
468
|
+
// Don't initialize, just try to list
|
|
469
|
+
let result = [];
|
|
470
|
+
try {
|
|
471
|
+
result = await manager.listScopes();
|
|
472
|
+
} catch {
|
|
473
|
+
// Expected - system not initialized
|
|
474
|
+
result = [];
|
|
475
|
+
}
|
|
476
|
+
assertEqual(result.length, 0, 'Should return empty or throw');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// Initialize for remaining tests
|
|
480
|
+
await manager.initialize();
|
|
481
|
+
|
|
482
|
+
// ========================================
|
|
483
|
+
// Error: Duplicate scope creation
|
|
484
|
+
// ========================================
|
|
485
|
+
await asyncTest('Creating duplicate scope throws meaningful error', async () => {
|
|
486
|
+
await manager.createScope('duptest', { name: 'Dup Test' });
|
|
487
|
+
|
|
488
|
+
let errorMsg = '';
|
|
489
|
+
try {
|
|
490
|
+
await manager.createScope('duptest', { name: 'Dup Test 2' });
|
|
491
|
+
} catch (error) {
|
|
492
|
+
errorMsg = error.message;
|
|
493
|
+
}
|
|
494
|
+
assertTrue(errorMsg.includes('already exists') || errorMsg.includes('duplicate'), `Error should mention duplicate: ${errorMsg}`);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// ========================================
|
|
498
|
+
// Error: Invalid operations on archived scope
|
|
499
|
+
// ========================================
|
|
500
|
+
await asyncTest('Operations on archived scope work correctly', async () => {
|
|
501
|
+
await manager.createScope('archtest', { name: 'Archive Test' });
|
|
502
|
+
await manager.archiveScope('archtest');
|
|
503
|
+
|
|
504
|
+
// Should still be able to get info
|
|
505
|
+
const scope = await manager.getScope('archtest');
|
|
506
|
+
assertEqual(scope.status, 'archived', 'Should get archived scope');
|
|
507
|
+
|
|
508
|
+
// Activate should work
|
|
509
|
+
await manager.activateScope('archtest');
|
|
510
|
+
const reactivated = await manager.getScope('archtest');
|
|
511
|
+
assertEqual(reactivated.status, 'active', 'Should be reactivated');
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// ========================================
|
|
515
|
+
// Edge: Scope with maximum valid name length
|
|
516
|
+
// ========================================
|
|
517
|
+
await asyncTest('Scope with maximum length name', async () => {
|
|
518
|
+
const longName = 'A'.repeat(200); // Very long name
|
|
519
|
+
const scope = await manager.createScope('longname', {
|
|
520
|
+
name: longName,
|
|
521
|
+
description: 'B'.repeat(500),
|
|
522
|
+
});
|
|
523
|
+
assertEqual(scope.id, 'longname', 'Should create scope with long name');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// ========================================
|
|
527
|
+
// Edge: Scope with special characters in name/description
|
|
528
|
+
// ========================================
|
|
529
|
+
await asyncTest('Scope with special characters in metadata', async () => {
|
|
530
|
+
const scope = await manager.createScope('specialchars', {
|
|
531
|
+
name: 'Test <script>alert("xss")</script>',
|
|
532
|
+
description: 'Description with "quotes" and \'apostrophes\' and `backticks`',
|
|
533
|
+
});
|
|
534
|
+
assertEqual(scope.id, 'specialchars', 'Should create scope with special chars in metadata');
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// ========================================
|
|
538
|
+
// Edge: Empty dependencies array
|
|
539
|
+
// ========================================
|
|
540
|
+
await asyncTest('Scope with empty dependencies array', async () => {
|
|
541
|
+
const scope = await manager.createScope('nodeps', {
|
|
542
|
+
name: 'No Deps',
|
|
543
|
+
dependencies: [],
|
|
544
|
+
});
|
|
545
|
+
assertEqual(scope.dependencies.length, 0, 'Should have no dependencies');
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// ========================================
|
|
549
|
+
// Sync operations on non-existent scope - documents current behavior
|
|
550
|
+
// ========================================
|
|
551
|
+
await asyncTest('Sync-up on non-existent scope handles gracefully', async () => {
|
|
552
|
+
const sync = new ScopeSync({ projectRoot: tmpDir });
|
|
553
|
+
|
|
554
|
+
// Current implementation may return empty result or throw
|
|
555
|
+
// This documents actual behavior
|
|
556
|
+
let result = null;
|
|
557
|
+
try {
|
|
558
|
+
result = await sync.syncUp('nonexistent');
|
|
559
|
+
// If it doesn't throw, result should indicate no files synced
|
|
560
|
+
assertTrue(result.promoted.length === 0 || result.success !== false, 'Should handle gracefully with no files to sync');
|
|
561
|
+
} catch {
|
|
562
|
+
// Throwing is also acceptable behavior
|
|
563
|
+
assertTrue(true, 'Throws for non-existent scope');
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// ========================================
|
|
568
|
+
// Edge: Rapid scope status changes
|
|
569
|
+
// ========================================
|
|
570
|
+
await asyncTest('Rapid archive/activate cycles', async () => {
|
|
571
|
+
await manager.createScope('rapidcycle', { name: 'Rapid Cycle' });
|
|
572
|
+
|
|
573
|
+
for (let i = 0; i < 5; i++) {
|
|
574
|
+
await manager.archiveScope('rapidcycle');
|
|
575
|
+
await manager.activateScope('rapidcycle');
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const scope = await manager.getScope('rapidcycle');
|
|
579
|
+
assertEqual(scope.status, 'active', 'Should end up active after cycles');
|
|
580
|
+
});
|
|
581
|
+
} finally {
|
|
582
|
+
if (tmpDir) {
|
|
583
|
+
cleanupTestProject(tmpDir);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ============================================================================
|
|
589
|
+
// E2E Test: Complex Dependency Scenarios
|
|
590
|
+
// ============================================================================
|
|
591
|
+
|
|
592
|
+
async function testComplexDependencyE2E() {
|
|
593
|
+
console.log(`\n${colors.blue}E2E: Complex Dependency Scenarios${colors.reset}`);
|
|
594
|
+
|
|
595
|
+
const { ScopeManager } = require('../src/core/lib/scope/scope-manager');
|
|
596
|
+
|
|
597
|
+
let tmpDir;
|
|
598
|
+
|
|
599
|
+
try {
|
|
600
|
+
tmpDir = createTestProject();
|
|
601
|
+
const manager = new ScopeManager({ projectRoot: tmpDir });
|
|
602
|
+
await manager.initialize();
|
|
603
|
+
|
|
604
|
+
// ========================================
|
|
605
|
+
// Diamond dependency pattern
|
|
606
|
+
// ========================================
|
|
607
|
+
await asyncTest('Diamond dependency pattern works', async () => {
|
|
608
|
+
// core
|
|
609
|
+
// / \
|
|
610
|
+
// auth user
|
|
611
|
+
// \ /
|
|
612
|
+
// payments
|
|
613
|
+
await manager.createScope('core', { name: 'Core' });
|
|
614
|
+
await manager.createScope('auth', { name: 'Auth', dependencies: ['core'] });
|
|
615
|
+
await manager.createScope('user', { name: 'User', dependencies: ['core'] });
|
|
616
|
+
await manager.createScope('payments', { name: 'Payments', dependencies: ['auth', 'user'] });
|
|
617
|
+
|
|
618
|
+
const payments = await manager.getScope('payments');
|
|
619
|
+
assertTrue(payments.dependencies.includes('auth'), 'Should depend on auth');
|
|
620
|
+
assertTrue(payments.dependencies.includes('user'), 'Should depend on user');
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// ========================================
|
|
624
|
+
// Finding all dependents in complex graph
|
|
625
|
+
// ========================================
|
|
626
|
+
await asyncTest('Finds all dependents in complex graph', async () => {
|
|
627
|
+
const coreDependents = await manager.findDependentScopes('core');
|
|
628
|
+
assertTrue(coreDependents.includes('auth'), 'auth should depend on core');
|
|
629
|
+
assertTrue(coreDependents.includes('user'), 'user should depend on core');
|
|
630
|
+
// Transitive dependents may or may not be included depending on implementation
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
// ========================================
|
|
634
|
+
// Removing scope in middle of dependency chain
|
|
635
|
+
// ========================================
|
|
636
|
+
await asyncTest('Cannot remove scope with dependents without force', async () => {
|
|
637
|
+
let threw = false;
|
|
638
|
+
try {
|
|
639
|
+
await manager.removeScope('auth'); // payments depends on auth
|
|
640
|
+
} catch {
|
|
641
|
+
threw = true;
|
|
642
|
+
}
|
|
643
|
+
assertTrue(threw, 'Should throw when removing scope with dependents');
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// ========================================
|
|
647
|
+
// Adding dependency to existing scope
|
|
648
|
+
// ========================================
|
|
649
|
+
await asyncTest('Adding new dependency to existing scope', async () => {
|
|
650
|
+
await manager.createScope('notifications', { name: 'Notifications' });
|
|
651
|
+
await manager.updateScope('payments', {
|
|
652
|
+
dependencies: ['auth', 'user', 'notifications'],
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
const payments = await manager.getScope('payments');
|
|
656
|
+
assertTrue(payments.dependencies.includes('notifications'), 'Should have new dependency');
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// ========================================
|
|
660
|
+
// Archiving scope in dependency chain
|
|
661
|
+
// ========================================
|
|
662
|
+
await asyncTest('Archiving scope in dependency chain', async () => {
|
|
663
|
+
await manager.archiveScope('auth');
|
|
664
|
+
|
|
665
|
+
// Payments should still exist and have auth as dependency
|
|
666
|
+
const payments = await manager.getScope('payments');
|
|
667
|
+
assertTrue(payments.dependencies.includes('auth'), 'Dependency should remain');
|
|
668
|
+
|
|
669
|
+
// Reactivate for cleanup
|
|
670
|
+
await manager.activateScope('auth');
|
|
671
|
+
});
|
|
672
|
+
} finally {
|
|
673
|
+
if (tmpDir) {
|
|
674
|
+
cleanupTestProject(tmpDir);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ============================================================================
|
|
680
|
+
// E2E Test: Sync Operations Edge Cases
|
|
681
|
+
// ============================================================================
|
|
682
|
+
|
|
683
|
+
async function testSyncOperationsE2E() {
|
|
684
|
+
console.log(`\n${colors.blue}E2E: Sync Operations Edge Cases${colors.reset}`);
|
|
685
|
+
|
|
686
|
+
const { ScopeManager } = require('../src/core/lib/scope/scope-manager');
|
|
687
|
+
const { ScopeSync } = require('../src/core/lib/scope/scope-sync');
|
|
688
|
+
|
|
689
|
+
let tmpDir;
|
|
690
|
+
|
|
691
|
+
try {
|
|
692
|
+
tmpDir = createTestProject();
|
|
693
|
+
const manager = new ScopeManager({ projectRoot: tmpDir });
|
|
694
|
+
await manager.initialize();
|
|
695
|
+
|
|
696
|
+
const sync = new ScopeSync({ projectRoot: tmpDir });
|
|
697
|
+
|
|
698
|
+
// Create test scope
|
|
699
|
+
await manager.createScope('synctest', { name: 'Sync Test' });
|
|
700
|
+
|
|
701
|
+
// ========================================
|
|
702
|
+
// Sync-up with no promotable files
|
|
703
|
+
// ========================================
|
|
704
|
+
await asyncTest('Sync-up with no promotable files', async () => {
|
|
705
|
+
// Create non-promotable file
|
|
706
|
+
const filePath = path.join(tmpDir, '_bmad-output', 'synctest', 'planning-artifacts', 'notes.md');
|
|
707
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
708
|
+
fs.writeFileSync(filePath, '# Random Notes');
|
|
709
|
+
|
|
710
|
+
const result = await sync.syncUp('synctest');
|
|
711
|
+
// Should succeed but with no files promoted
|
|
712
|
+
assertTrue(result.success || result.promoted.length === 0, 'Should handle no promotable files');
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// ========================================
|
|
716
|
+
// Sync-up with empty architecture directory
|
|
717
|
+
// ========================================
|
|
718
|
+
await asyncTest('Sync-up with empty promotable directory', async () => {
|
|
719
|
+
// Create empty architecture directory
|
|
720
|
+
fs.mkdirSync(path.join(tmpDir, '_bmad-output', 'synctest', 'architecture'), { recursive: true });
|
|
721
|
+
|
|
722
|
+
const result = await sync.syncUp('synctest');
|
|
723
|
+
assertTrue(result.success !== false, 'Should handle empty directory');
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
// ========================================
|
|
727
|
+
// Sync-up with binary files (should skip)
|
|
728
|
+
// ========================================
|
|
729
|
+
await asyncTest('Sync-up skips binary files', async () => {
|
|
730
|
+
// Create a file that might be considered binary
|
|
731
|
+
const archPath = path.join(tmpDir, '_bmad-output', 'synctest', 'architecture', 'diagram.png');
|
|
732
|
+
fs.mkdirSync(path.dirname(archPath), { recursive: true });
|
|
733
|
+
fs.writeFileSync(archPath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); // PNG header
|
|
734
|
+
|
|
735
|
+
const result = await sync.syncUp('synctest');
|
|
736
|
+
// Should succeed, binary might be skipped or included depending on implementation
|
|
737
|
+
assertTrue(result.success !== false, 'Should handle binary files gracefully');
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// ========================================
|
|
741
|
+
// Create scope when directory already exists - safe by default
|
|
742
|
+
// ========================================
|
|
743
|
+
await asyncTest('Creating scope when directory exists throws by default', async () => {
|
|
744
|
+
// Pre-create the directory
|
|
745
|
+
fs.mkdirSync(path.join(tmpDir, '_bmad-output', 'preexist', 'planning-artifacts'), { recursive: true });
|
|
746
|
+
fs.writeFileSync(path.join(tmpDir, '_bmad-output', 'preexist', 'planning-artifacts', 'existing.md'), '# Existing File');
|
|
747
|
+
|
|
748
|
+
// Create scope - should throw because directory exists (safe default)
|
|
749
|
+
let threw = false;
|
|
750
|
+
let errorMsg = '';
|
|
751
|
+
try {
|
|
752
|
+
await manager.createScope('preexist', { name: 'Pre-existing' });
|
|
753
|
+
} catch (error) {
|
|
754
|
+
threw = true;
|
|
755
|
+
errorMsg = error.message;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
assertTrue(threw, 'Should throw when directory exists');
|
|
759
|
+
assertTrue(errorMsg.includes('already exists'), 'Error should mention directory exists');
|
|
760
|
+
|
|
761
|
+
// Existing file should still be preserved
|
|
762
|
+
const existingContent = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'preexist', 'planning-artifacts', 'existing.md'), 'utf8');
|
|
763
|
+
assertTrue(existingContent.includes('Existing File'), 'Should preserve existing files');
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// ========================================
|
|
767
|
+
// Sync with very long file paths
|
|
768
|
+
// ========================================
|
|
769
|
+
await asyncTest('Sync handles deeply nested paths', async () => {
|
|
770
|
+
const deepPath = path.join(tmpDir, '_bmad-output', 'synctest', 'architecture', 'deep', 'nested', 'structure', 'document.md');
|
|
771
|
+
fs.mkdirSync(path.dirname(deepPath), { recursive: true });
|
|
772
|
+
fs.writeFileSync(deepPath, '# Deeply Nested');
|
|
773
|
+
|
|
774
|
+
const result = await sync.syncUp('synctest');
|
|
775
|
+
assertTrue(result.success !== false, 'Should handle deep paths');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// ========================================
|
|
779
|
+
// Sync with special characters in filename
|
|
780
|
+
// ========================================
|
|
781
|
+
await asyncTest('Sync handles special characters in filenames', async () => {
|
|
782
|
+
const specialPath = path.join(tmpDir, '_bmad-output', 'synctest', 'architecture', 'design (v2) [draft].md');
|
|
783
|
+
fs.mkdirSync(path.dirname(specialPath), { recursive: true });
|
|
784
|
+
fs.writeFileSync(specialPath, '# Design v2 Draft');
|
|
785
|
+
|
|
786
|
+
const result = await sync.syncUp('synctest');
|
|
787
|
+
assertTrue(result.success !== false, 'Should handle special chars in filenames');
|
|
788
|
+
});
|
|
789
|
+
} finally {
|
|
790
|
+
if (tmpDir) {
|
|
791
|
+
cleanupTestProject(tmpDir);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ============================================================================
|
|
797
|
+
// E2E Test: File System Edge Cases
|
|
798
|
+
// ============================================================================
|
|
799
|
+
|
|
800
|
+
async function testFileSystemEdgeCasesE2E() {
|
|
801
|
+
console.log(`\n${colors.blue}E2E: File System Edge Cases${colors.reset}`);
|
|
802
|
+
|
|
803
|
+
const { ScopeManager } = require('../src/core/lib/scope/scope-manager');
|
|
804
|
+
const { ScopeInitializer } = require('../src/core/lib/scope/scope-initializer');
|
|
805
|
+
|
|
806
|
+
let tmpDir;
|
|
807
|
+
|
|
808
|
+
try {
|
|
809
|
+
tmpDir = createTestProject();
|
|
810
|
+
const manager = new ScopeManager({ projectRoot: tmpDir });
|
|
811
|
+
const initializer = new ScopeInitializer({ projectRoot: tmpDir });
|
|
812
|
+
await manager.initialize();
|
|
813
|
+
|
|
814
|
+
// ========================================
|
|
815
|
+
// Remove scope with readonly files
|
|
816
|
+
// ========================================
|
|
817
|
+
await asyncTest('Remove scope handles readonly files', async () => {
|
|
818
|
+
await manager.createScope('readonly', { name: 'Readonly Test' });
|
|
819
|
+
|
|
820
|
+
// Make a file readonly
|
|
821
|
+
const filePath = path.join(tmpDir, '_bmad-output', 'readonly', 'planning-artifacts', 'locked.md');
|
|
822
|
+
fs.writeFileSync(filePath, '# Locked');
|
|
823
|
+
try {
|
|
824
|
+
fs.chmodSync(filePath, 0o444); // Read-only
|
|
825
|
+
} catch {
|
|
826
|
+
// Windows might not support chmod
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Remove should handle this gracefully
|
|
830
|
+
let removed = false;
|
|
831
|
+
try {
|
|
832
|
+
await initializer.removeScope('readonly', { backup: false });
|
|
833
|
+
await manager.removeScope('readonly', { force: true });
|
|
834
|
+
removed = true;
|
|
835
|
+
} catch {
|
|
836
|
+
// May fail on some systems, that's ok
|
|
837
|
+
// Clean up by making it writable again
|
|
838
|
+
try {
|
|
839
|
+
fs.chmodSync(filePath, 0o644);
|
|
840
|
+
await initializer.removeScope('readonly', { backup: false });
|
|
841
|
+
await manager.removeScope('readonly', { force: true });
|
|
842
|
+
removed = true;
|
|
843
|
+
} catch {
|
|
844
|
+
// Ignore cleanup errors
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
// Just verify it attempted the operation
|
|
848
|
+
assertTrue(true, 'Attempted removal of readonly files');
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
// ========================================
|
|
852
|
+
// Scope with symlinks (if supported)
|
|
853
|
+
// ========================================
|
|
854
|
+
await asyncTest('Scope handles symlinks gracefully', async () => {
|
|
855
|
+
await manager.createScope('symtest', { name: 'Symlink Test' });
|
|
856
|
+
|
|
857
|
+
const targetPath = path.join(tmpDir, '_bmad-output', 'symtest', 'planning-artifacts', 'target.md');
|
|
858
|
+
const linkPath = path.join(tmpDir, '_bmad-output', 'symtest', 'planning-artifacts', 'link.md');
|
|
859
|
+
|
|
860
|
+
fs.writeFileSync(targetPath, '# Target');
|
|
861
|
+
|
|
862
|
+
try {
|
|
863
|
+
fs.symlinkSync(targetPath, linkPath);
|
|
864
|
+
|
|
865
|
+
// Should be able to read through symlink
|
|
866
|
+
const content = fs.readFileSync(linkPath, 'utf8');
|
|
867
|
+
assertTrue(content.includes('Target'), 'Should read through symlink');
|
|
868
|
+
} catch {
|
|
869
|
+
// Symlinks may not be supported on all systems
|
|
870
|
+
assertTrue(true, 'Symlinks not supported on this system');
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
// ========================================
|
|
875
|
+
// Large number of files in scope
|
|
876
|
+
// ========================================
|
|
877
|
+
await asyncTest('Scope with many files', async () => {
|
|
878
|
+
await manager.createScope('manyfiles', { name: 'Many Files' });
|
|
879
|
+
|
|
880
|
+
const planningDir = path.join(tmpDir, '_bmad-output', 'manyfiles', 'planning-artifacts');
|
|
881
|
+
|
|
882
|
+
// Create 100 files
|
|
883
|
+
for (let i = 0; i < 100; i++) {
|
|
884
|
+
fs.writeFileSync(path.join(planningDir, `file-${i}.md`), `# File ${i}`);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Should still be able to manage scope
|
|
888
|
+
const scope = await manager.getScope('manyfiles');
|
|
889
|
+
assertEqual(scope.id, 'manyfiles', 'Should manage scope with many files');
|
|
890
|
+
});
|
|
891
|
+
} finally {
|
|
892
|
+
if (tmpDir) {
|
|
893
|
+
cleanupTestProject(tmpDir);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ============================================================================
|
|
899
|
+
// E2E Test: Concurrent Operations Stress Test
|
|
900
|
+
// ============================================================================
|
|
901
|
+
|
|
902
|
+
async function testConcurrentOperationsE2E() {
|
|
903
|
+
console.log(`\n${colors.blue}E2E: Concurrent Operations Stress Test${colors.reset}`);
|
|
904
|
+
|
|
905
|
+
const { ScopeManager } = require('../src/core/lib/scope/scope-manager');
|
|
906
|
+
const { ScopeContext } = require('../src/core/lib/scope/scope-context');
|
|
907
|
+
|
|
908
|
+
let tmpDir;
|
|
909
|
+
|
|
910
|
+
try {
|
|
911
|
+
tmpDir = createTestProject();
|
|
912
|
+
const manager = new ScopeManager({ projectRoot: tmpDir });
|
|
913
|
+
await manager.initialize();
|
|
914
|
+
|
|
915
|
+
// ========================================
|
|
916
|
+
// Concurrent scope creation stress test
|
|
917
|
+
// ========================================
|
|
918
|
+
await asyncTest('Concurrent scope creations (stress test)', async () => {
|
|
919
|
+
const createPromises = [];
|
|
920
|
+
for (let i = 0; i < 20; i++) {
|
|
921
|
+
createPromises.push(
|
|
922
|
+
manager
|
|
923
|
+
.createScope(`concurrent-${i}`, { name: `Concurrent ${i}` })
|
|
924
|
+
.catch((error) => ({ error: error.message, id: `concurrent-${i}` })),
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const results = await Promise.all(createPromises);
|
|
929
|
+
|
|
930
|
+
// Count successes
|
|
931
|
+
const successes = results.filter((r) => !r.error);
|
|
932
|
+
assertTrue(successes.length > 0, 'At least some concurrent creates should succeed');
|
|
933
|
+
|
|
934
|
+
// Verify all created scopes exist
|
|
935
|
+
const scopes = await manager.listScopes();
|
|
936
|
+
assertTrue(scopes.length >= successes.length, 'All successful creates should persist');
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
// ========================================
|
|
940
|
+
// Concurrent read/write operations
|
|
941
|
+
// ========================================
|
|
942
|
+
await asyncTest('Concurrent reads during writes', async () => {
|
|
943
|
+
await manager.createScope('rwtest', { name: 'Read/Write Test' });
|
|
944
|
+
|
|
945
|
+
const operations = [];
|
|
946
|
+
|
|
947
|
+
// Mix of reads and writes
|
|
948
|
+
for (let i = 0; i < 10; i++) {
|
|
949
|
+
if (i % 2 === 0) {
|
|
950
|
+
// Read
|
|
951
|
+
operations.push(manager.getScope('rwtest'));
|
|
952
|
+
} else {
|
|
953
|
+
// Write (update)
|
|
954
|
+
operations.push(manager.updateScope('rwtest', { description: `Update ${i}` }));
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
await Promise.all(operations);
|
|
959
|
+
|
|
960
|
+
// Verify scope is still valid
|
|
961
|
+
const scope = await manager.getScope('rwtest');
|
|
962
|
+
assertEqual(scope.id, 'rwtest', 'Scope should still be valid');
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// ========================================
|
|
966
|
+
// Concurrent context switches
|
|
967
|
+
// ========================================
|
|
968
|
+
await asyncTest('Concurrent context switches', async () => {
|
|
969
|
+
const context1 = new ScopeContext({ projectRoot: tmpDir });
|
|
970
|
+
const context2 = new ScopeContext({ projectRoot: tmpDir });
|
|
971
|
+
|
|
972
|
+
// Both try to set different scopes
|
|
973
|
+
const [, scope1, scope2] = await Promise.all([
|
|
974
|
+
manager.createScope('ctx1', { name: 'Context 1' }),
|
|
975
|
+
context1.setScope('rwtest').then(() => context1.getCurrentScope()),
|
|
976
|
+
context2.setScope('rwtest').then(() => context2.getCurrentScope()),
|
|
977
|
+
]);
|
|
978
|
+
|
|
979
|
+
// One should win (last write wins)
|
|
980
|
+
const finalScope = await context1.getCurrentScope();
|
|
981
|
+
assertTrue(finalScope === 'rwtest', 'Should have a valid scope set');
|
|
399
982
|
});
|
|
400
|
-
|
|
401
983
|
} finally {
|
|
402
984
|
if (tmpDir) {
|
|
403
985
|
cleanupTestProject(tmpDir);
|
|
@@ -417,6 +999,14 @@ async function main() {
|
|
|
417
999
|
try {
|
|
418
1000
|
await testParallelScopeWorkflow();
|
|
419
1001
|
await testConcurrentLockSimulation();
|
|
1002
|
+
|
|
1003
|
+
// New comprehensive E2E tests
|
|
1004
|
+
await testHelpCommandsE2E();
|
|
1005
|
+
await testErrorHandlingE2E();
|
|
1006
|
+
await testComplexDependencyE2E();
|
|
1007
|
+
await testSyncOperationsE2E();
|
|
1008
|
+
await testFileSystemEdgeCasesE2E();
|
|
1009
|
+
await testConcurrentOperationsE2E();
|
|
420
1010
|
} catch (error) {
|
|
421
1011
|
console.log(`\n${colors.red}Fatal error: ${error.message}${colors.reset}`);
|
|
422
1012
|
console.log(error.stack);
|