@activate-core/test-forge 0.9.7 → 0.10.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.
@@ -187,6 +187,36 @@ export async function runHarness(only) {
187
187
  }
188
188
  return written;
189
189
  }
190
+ const PLAN_DIR = '.testforge/plans';
191
+ /**
192
+ * Get a journey's file plan, cached by doc hash. Caching the plan keeps
193
+ * the file slugs STABLE across runs, so per-file resume (skip-existing)
194
+ * actually matches — and it saves the planner AI call on a resume. When
195
+ * the doc changes, the hash changes and we re-plan.
196
+ */
197
+ function loadOrPlan(journey, docHash) {
198
+ const p = join(PLAN_DIR, `${journey.area}__${journey.id}.json`);
199
+ if (existsSync(p)) {
200
+ try {
201
+ const cached = JSON.parse(readFileSync(p, 'utf-8'));
202
+ if (cached.hash === docHash && Array.isArray(cached.files) && cached.files.length) {
203
+ return cached.files;
204
+ }
205
+ }
206
+ catch {
207
+ // corrupt plan cache — re-plan
208
+ }
209
+ }
210
+ const files = planSuite(journey);
211
+ try {
212
+ mkdirSync(PLAN_DIR, { recursive: true });
213
+ writeFileSync(p, JSON.stringify({ hash: docHash, files }, null, 2), 'utf-8');
214
+ }
215
+ catch {
216
+ // best-effort cache
217
+ }
218
+ return files;
219
+ }
190
220
  /** Does this doc file / journey match a --journey selector? */
191
221
  function matchesJourney(file, journeyId, sel) {
192
222
  const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-');
@@ -301,7 +331,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
301
331
  // 1. Plan the suite (deep mode), then generate one focused file per
302
332
  // concern. In basic mode there is no AI planner — one file only.
303
333
  const planned = useAI
304
- ? planSuite(journey)
334
+ ? loadOrPlan(journey, hash)
305
335
  : [{ slug: journey.id, title: journey.name, covers: '' }];
306
336
  // Generate all focused files CONCURRENTLY (bounded pool). Each AI
307
337
  // call is independent, so a 15-file journey finishes in ~4 waves
@@ -312,6 +342,20 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
312
342
  // folder already namespaces it, so the slug alone is enough.
313
343
  const base = flat ? `${journey.id}-${pf.slug}` : pf.slug;
314
344
  const filePath = join(testDir, `${base}${ext}`);
345
+ // PER-FILE RESUME: if this file already exists and parses, keep it
346
+ // — don't spend tokens regenerating it. So a journey interrupted
347
+ // midway resumes from exactly the files it hadn't written yet.
348
+ // (--force ignores this and regenerates.)
349
+ if (!force && existsSync(filePath)) {
350
+ try {
351
+ if (isSyntaxValid(readFileSync(filePath, 'utf-8'), ext)) {
352
+ return { slug: pf.slug, path: filePath, status: 'ai' };
353
+ }
354
+ }
355
+ catch {
356
+ // unreadable — fall through and regenerate
357
+ }
358
+ }
315
359
  let content;
316
360
  let status;
317
361
  if (useAI) {
@@ -331,14 +375,17 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
331
375
  content = built.content;
332
376
  status = 'basic';
333
377
  }
334
- return { slug: pf.slug, path: filePath, content, status };
378
+ // Write IMMEDIATELY (not batched at the end of the journey), so an
379
+ // interruption preserves every file generated so far.
380
+ mkdirSync(dirname(filePath), { recursive: true });
381
+ writeFileSync(filePath, content, 'utf-8');
382
+ return { slug: pf.slug, path: filePath, status };
335
383
  });
336
- const testFiles = [];
337
- for (const g of generated) {
338
- mkdirSync(dirname(g.path), { recursive: true });
339
- writeFileSync(g.path, g.content, 'utf-8');
340
- testFiles.push({ slug: g.slug, path: g.path, status: g.status });
341
- }
384
+ const testFiles = generated.map((g) => ({
385
+ slug: g.slug,
386
+ path: g.path,
387
+ status: g.status,
388
+ }));
342
389
  const anyAI = testFiles.some((t) => t.status === 'ai');
343
390
  const anyBasic = testFiles.some((t) => t.status === 'basic');
344
391
  const status = anyAI || !anyBasic ? 'ai' : 'basic';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@activate-core/test-forge",
3
- "version": "0.9.7",
3
+ "version": "0.10.0",
4
4
  "description": "AI-powered QA automation. Scans a project, drafts journey docs, and generates seeded Jest/Playwright tests. Works in any project, with any AI CLI.",
5
5
  "type": "module",
6
6
  "bin": {