ndomo 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.env.example +4 -0
  2. package/README.es.md +29 -23
  3. package/README.md +64 -24
  4. package/bun.lock +447 -0
  5. package/docs/configuration.md +4 -4
  6. package/docs/installation.md +53 -34
  7. package/docs/installer.md +164 -0
  8. package/docs/integrations.md +1 -1
  9. package/docs/web-ui.md +124 -0
  10. package/package.json +43 -4
  11. package/scripts/install.sh +28 -0
  12. package/scripts/smoke-install.sh +47 -0
  13. package/scripts/smoke-web.sh +335 -0
  14. package/src/cli/__tests__/install.test.ts +733 -0
  15. package/src/cli/index.ts +8 -0
  16. package/src/cli/install.ts +1273 -0
  17. package/src/config/__tests__/schema.test.ts +223 -0
  18. package/src/config/schema.ts +129 -16
  19. package/src/http/__tests__/auth.test.ts +10 -10
  20. package/src/http/__tests__/spa.test.ts +296 -0
  21. package/src/http/auth.ts +8 -1
  22. package/src/http/server.ts +71 -2
  23. package/.bun-version +0 -1
  24. package/.dockerignore +0 -79
  25. package/.editorconfig +0 -18
  26. package/.github/CODEOWNERS +0 -8
  27. package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
  28. package/.github/ISSUE_TEMPLATE/config.yml +0 -2
  29. package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -34
  30. package/.github/dependabot.yml +0 -36
  31. package/.github/pull_request_template.md +0 -24
  32. package/.github/release.yml +0 -30
  33. package/.github/workflows/gitleaks.yml +0 -28
  34. package/.github/workflows/release-please.yml +0 -27
  35. package/.github/workflows/smoke.yml +0 -29
  36. package/.husky/commit-msg +0 -1
  37. package/CHANGELOG.md +0 -114
  38. package/Dockerfile +0 -32
  39. package/bin/ndomo-analyses.ts +0 -4
  40. package/bin/ndomo-status.ts +0 -4
  41. package/biome.json +0 -57
  42. package/commitlint.config.js +0 -3
  43. package/opencode.json +0 -5
  44. package/release-please-config.json +0 -11
  45. package/scripts/dev-bust-cache.sh +0 -164
  46. package/scripts/smoke-e2e.ts +0 -704
  47. package/scripts/smoke-hot.ts +0 -417
  48. package/scripts/smoke-v4.ts +0 -256
  49. package/scripts/smoke-v5.ts +0 -397
  50. package/scripts/uninstall.sh +0 -224
  51. package/src/index.ts +0 -37
  52. package/src/lib.ts +0 -65
  53. package/src/mem/scoped.ts +0 -65
  54. package/src/orchestrator/background.test.ts +0 -268
  55. package/src/orchestrator/background.ts +0 -293
  56. package/src/orchestrator/memory-hook.ts +0 -182
  57. package/src/orchestrator/reconciler.ts +0 -123
  58. package/src/orchestrator/scheduler.test.ts +0 -300
  59. package/src/orchestrator/scheduler.ts +0 -243
  60. package/src/plugin.test.ts +0 -2574
  61. package/src/plugin.ts +0 -1690
  62. package/src/worktrees/manager.ts +0 -236
  63. package/src/worktrees/state.ts +0 -87
  64. package/tests/integration/ranger-flow.test.ts +0 -257
  65. package/tsconfig.json +0 -31
@@ -1,704 +0,0 @@
1
- /**
2
- * ndomo v5 E2E stress test — full plan lifecycle post-hotfix.
3
- *
4
- * Verifies FTS5 correctness (the critical v4→v5 bug), archive lifecycle,
5
- * migration idempotency, and edge cases.
6
- *
7
- * Stack: TS + Bun + bun:sqlite. NO code modifications — read + execute only.
8
- *
9
- * Usage: bun run scripts/smoke-e2e.ts
10
- */
11
-
12
- import { Database } from "bun:sqlite";
13
- import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
14
- import { tmpdir } from "node:os";
15
- import { join } from "node:path";
16
- import { runMigrations } from "../src/db/migrations.ts";
17
- import { archivePlan } from "../src/db/plan-archive.ts";
18
- import {
19
- addPlanTag,
20
- approvePlan,
21
- createPlan,
22
- findPlansByCategory,
23
- findPlansByTag,
24
- getPlan,
25
- getPlanProgress,
26
- listPlans,
27
- searchPlans,
28
- updatePlanStatus,
29
- } from "../src/db/plans.ts";
30
- import { checkpointSession, startSession } from "../src/db/sessions.ts";
31
- import {
32
- createTasksBatch,
33
- nextTaskForAgent,
34
- searchTasks,
35
- updateTaskStatus,
36
- } from "../src/db/tasks.ts";
37
- import type { Plan, PlanCategory } from "../src/db/types.ts";
38
-
39
- // ── Test harness ─────────────────────────────────────────────────────────────
40
-
41
- let pass = 0;
42
- let fail = 0;
43
- const failures: Array<{ test: string; msg: string }> = [];
44
-
45
- function assert(cond: boolean, msg: string): void {
46
- if (cond) {
47
- pass++;
48
- console.log(` ✓ ${msg}`);
49
- } else {
50
- fail++;
51
- failures.push({ test: "current", msg });
52
- console.error(` ✗ FAIL: ${msg}`);
53
- }
54
- }
55
-
56
- function assertThrows(fn: () => void, expectedMsg: string, msg: string): void {
57
- try {
58
- fn();
59
- fail++;
60
- failures.push({ test: "current", msg: `${msg} (expected error, got none)` });
61
- console.error(` ✗ FAIL: ${msg} (expected error, got none)`);
62
- } catch (err) {
63
- const errMsg = err instanceof Error ? err.message : String(err);
64
- if (errMsg.includes(expectedMsg)) {
65
- pass++;
66
- console.log(` ✓ ${msg}`);
67
- } else {
68
- fail++;
69
- failures.push({
70
- test: "current",
71
- msg: `${msg} (expected '${expectedMsg}', got '${errMsg}')`,
72
- });
73
- console.error(` ✗ FAIL: ${msg} (expected '${expectedMsg}', got '${errMsg}')`);
74
- }
75
- }
76
- }
77
-
78
- function section(name: string): void {
79
- console.log(`\n${"─".repeat(60)}`);
80
- console.log(` ${name}`);
81
- console.log(`${"─".repeat(60)}`);
82
- }
83
-
84
- // ── Defaults ─────────────────────────────────────────────────────────────────
85
-
86
- const PLAN_DEFAULTS = {
87
- status: "draft" as const,
88
- priority: 3,
89
- overview: "smoke test plan overview for validation",
90
- complexity: 3,
91
- createdBy: "smoke-e2e",
92
- updatedBy: "smoke-e2e",
93
- metadata: {},
94
- approvedAt: null,
95
- completedAt: null,
96
- sessionId: null,
97
- approach: "Test approach for E2E smoke validation",
98
- sourceSessionId: null,
99
- sourceMessageId: null,
100
- category: null as Plan["category"],
101
- archivedAt: null,
102
- };
103
-
104
- const TASK_DEFAULTS = {
105
- agent: "smith",
106
- files: [] as string[],
107
- complexity: 3,
108
- createdBy: "smoke-e2e",
109
- updatedBy: "smoke-e2e",
110
- sourceSessionId: null,
111
- sourceMessageId: null,
112
- reviewedBy: null,
113
- tokensUsed: null,
114
- durationMs: null,
115
- artifacts: [] as string[],
116
- dependencies: [] as string[],
117
- metadata: {},
118
- };
119
-
120
- function mkPlan(
121
- id: string,
122
- slug: string,
123
- title: string,
124
- overrides?: Partial<typeof PLAN_DEFAULTS>,
125
- ) {
126
- return createPlan(db, { ...PLAN_DEFAULTS, id, slug, title, ...overrides });
127
- }
128
-
129
- // ── Main ─────────────────────────────────────────────────────────────────────
130
-
131
- const db = new Database(":memory:");
132
- db.exec("PRAGMA journal_mode = WAL");
133
- db.exec("PRAGMA foreign_keys = ON");
134
-
135
- const testMemDir = join(tmpdir(), `ndomo-smoke-e2e-${Date.now()}`);
136
- mkdirSync(testMemDir, { recursive: true });
137
-
138
- console.log("ndomo v5 E2E stress test\n");
139
-
140
- // ── Run migrations v1→v5 ────────────────────────────────────────────────────
141
- console.log("Running migrations v1→v5...");
142
- runMigrations(db);
143
- const ver = db.query("SELECT MAX(version) as v FROM schema_version").get() as { v: number };
144
- assert(ver.v === 5, `schema_version = 5 (got ${ver.v})`);
145
-
146
- // ═════════════════════════════════════════════════════════════════════════════
147
- // TEST 1: FTS5 positivo (CRÍTICO — antes broken por content='')
148
- // ═════════════════════════════════════════════════════════════════════════════
149
- section("Test 1: FTS5 positivo — searchPlans con keywords diferentes");
150
-
151
- mkPlan("fts-auth", "fts-auth", "Authentication bug in login flow");
152
- mkPlan("fts-authz", "fts-authz", "Authorization refactor for role-based access");
153
- mkPlan("fts-perf", "fts-perf", "Performance optimization for database queries");
154
-
155
- const r1 = searchPlans(db, "authentication");
156
- assert(r1.length === 1, `searchPlans("authentication") → 1 result (got ${r1.length})`);
157
- assert(r1[0]?.id === "fts-auth", `searchPlans("authentication") → fts-auth (got ${r1[0]?.id})`);
158
-
159
- const r2 = searchPlans(db, "access");
160
- assert(r2.length === 1, `searchPlans("access") → 1 result (got ${r2.length})`);
161
- assert(r2[0]?.id === "fts-authz", `searchPlans("access") → fts-authz (got ${r2[0]?.id})`);
162
-
163
- const r3 = searchPlans(db, "optimization");
164
- assert(r3.length === 1, `searchPlans("optimization") → 1 result (got ${r3.length})`);
165
- assert(r3[0]?.id === "fts-perf", `searchPlans("optimization") → fts-perf (got ${r3[0]?.id})`);
166
-
167
- // BUG-1 FIX: FTS5 MATCH no longer throws on hyphens — escapeFtsQuery wraps input in "..."
168
- // searchPlans("nonexistent-xyz") should return 0 results, NOT throw SQLiteError
169
- const rHyphen = searchPlans(db, "nonexistent-xyz");
170
- assert(
171
- rHyphen.length === 0,
172
- `searchPlans("nonexistent-xyz") → [] (no throw, hyphen safe) (got ${rHyphen.length})`,
173
- );
174
-
175
- // Use safe search term (no hyphens) for the actual empty-results assertion
176
- const rEmpty = searchPlans(db, "nonexistentxyz12345");
177
- assert(rEmpty.length === 0, `searchPlans("nonexistentxyz12345") → [] (got ${rEmpty.length})`);
178
-
179
- // Multi-match: "user" appears in both titles
180
- mkPlan("fts-login", "fts-login", "user login authentication flow");
181
- mkPlan("fts-logout", "fts-logout", "user logout cleanup handler");
182
- const rMulti = searchPlans(db, "user");
183
- assert(rMulti.length === 2, `searchPlans("user") → 2 results (got ${rMulti.length})`);
184
- const multiIds = rMulti.map((p) => p.id).sort();
185
- assert(
186
- multiIds.includes("fts-login") && multiIds.includes("fts-logout"),
187
- `searchPlans("user") includes fts-login and fts-logout (got [${multiIds.join(", ")}])`,
188
- );
189
-
190
- // ═════════════════════════════════════════════════════════════════════════════
191
- // TEST 2: FTS5 con acentos (remove_diacritics)
192
- // ═════════════════════════════════════════════════════════════════════════════
193
- section("Test 2: FTS5 con acentos — remove_diacritics");
194
-
195
- mkPlan("fts-acc", "fts-acc", "Plan de Acción correctiva");
196
-
197
- const rAcc1 = searchPlans(db, "accion");
198
- assert(rAcc1.length === 1, `searchPlans("accion") finds "Acción" (got ${rAcc1.length})`);
199
- assert(rAcc1[0]?.id === "fts-acc", `searchPlans("accion") → fts-acc (got ${rAcc1[0]?.id})`);
200
-
201
- const rAcc2 = searchPlans(db, "acción");
202
- assert(rAcc2.length === 1, `searchPlans("acción") finds "Acción" (got ${rAcc2.length})`);
203
- assert(rAcc2[0]?.id === "fts-acc", `searchPlans("acción") → fts-acc (got ${rAcc2[0]?.id})`);
204
-
205
- // ═════════════════════════════════════════════════════════════════════════════
206
- // TEST 2b: BUG-1 fix — escapeFtsQuery validation
207
- // ═════════════════════════════════════════════════════════════════════════════
208
- section("Test 2b: BUG-1 fix — hyphens, quotes, diacritics via escapeFtsQuery");
209
-
210
- // Hyphenated phrase: "auth-bug" treated as literal phrase, not column qualifier
211
- mkPlan("fts-hyphen", "fts-hyphen", "Fix auth-bug in login module");
212
- const rHyphenSearch = searchPlans(db, "auth-bug");
213
- assert(
214
- rHyphenSearch.length === 1,
215
- `searchPlans("auth-bug") → 1 result (got ${rHyphenSearch.length})`,
216
- );
217
- assert(
218
- rHyphenSearch[0]?.id === "fts-hyphen",
219
- `searchPlans("auth-bug") → fts-hyphen (got ${rHyphenSearch[0]?.id})`,
220
- );
221
-
222
- // Internal quotes: must be escaped, not throw
223
- mkPlan("fts-quotes", "fts-quotes", 'Plan with "quotes" in title');
224
- const rQuotes = searchPlans(db, 'term with "quotes"');
225
- assert(
226
- rQuotes.length === 0,
227
- `searchPlans('term with "quotes"') → 0 results, no throw (got ${rQuotes.length})`,
228
- );
229
-
230
- // Diacritics with explicit "Accion" plan (confirm accent-insensitive search)
231
- mkPlan("fts-accion", "fts-accion", "Accion correctiva urgente");
232
- const rAccion = searchPlans(db, "acción");
233
- assert(
234
- rAccion.some((p) => p.id === "fts-accion"),
235
- `searchPlans("acción") finds "Accion" plan (fts-accion)`,
236
- );
237
-
238
- // searchTasks with hyphens: also uses escapeFtsQuery
239
- createTasksBatch(db, "fts-hyphen", [
240
- { ...TASK_DEFAULTS, description: "Fix auth-bug in API endpoint", orderIndex: 0 },
241
- ]);
242
- const rTaskHyphen = searchTasks(db, "auth-bug");
243
- assert(rTaskHyphen.length >= 1, `searchTasks("auth-bug") → ≥1 result (got ${rTaskHyphen.length})`);
244
-
245
- // ═════════════════════════════════════════════════════════════════════════════
246
- // TEST 3: FTS5 stopwords
247
- // ═════════════════════════════════════════════════════════════════════════════
248
- section("Test 3: FTS5 stopwords — 'el' es stopword español");
249
-
250
- mkPlan("fts-stop", "fts-stop", "El plan para hacer algo importante");
251
-
252
- // "el" is a Spanish stopword in the fts5_stopwords table, BUT the FTS5 tokenizer
253
- // (unicode61 remove_diacritics 1) does NOT reference that custom stopwords table.
254
- // unicode61 default stopword list does not include Spanish words.
255
- // Spec says "[] o muy pocos" — we document this as a finding and accept ≤1.
256
- const rStop = searchPlans(db, "el");
257
- if (rStop.length > 0) {
258
- console.log(
259
- " ⚠ FINDING: searchPlans('el') returned results — fts5_stopwords table not wired to FTS5 tokenizer",
260
- );
261
- console.log(
262
- " → unicode61 remove_diacritics 1 does NOT use custom stopwords. Spanish stopwords are decorative.",
263
- );
264
- }
265
- assert(
266
- rStop.length <= 1,
267
- `searchPlans("el") → [] o muy pocos (got ${rStop.length}, spec allows ≤1)`,
268
- );
269
-
270
- // "plan" is NOT a stopword → should find it
271
- const rPlan = searchPlans(db, "plan");
272
- assert(rPlan.length >= 1, `searchPlans("plan") finds the plan (got ${rPlan.length})`);
273
- assert(
274
- rPlan.some((p) => p.id === "fts-stop"),
275
- `searchPlans("plan") includes fts-stop`,
276
- );
277
-
278
- // ═════════════════════════════════════════════════════════════════════════════
279
- // TEST 4: Plan lifecycle completo
280
- // ═════════════════════════════════════════════════════════════════════════════
281
- section(
282
- "Test 4: Plan lifecycle completo — create → approve → tasks → session → complete → archive",
283
- );
284
-
285
- // 4.1 Create plan (status=draft)
286
- const lifecyclePlan = mkPlan("lc-plan", "lifecycle-test", "Full Lifecycle Test Plan");
287
- assert(
288
- lifecyclePlan.status === "draft",
289
- `plan created with status=draft (got ${lifecyclePlan.status})`,
290
- );
291
-
292
- // 4.2 Approve plan
293
- const approvedPlan = approvePlan(db, "lc-plan", { updatedBy: "smoke-e2e" });
294
- assert(approvedPlan !== null, "approvePlan returned plan");
295
- assert(approvedPlan?.status === "approved", `plan approved (got ${approvedPlan?.status})`);
296
- assert(approvedPlan?.approvedAt !== null, "approved_at is set");
297
-
298
- // 4.3 Create 3 tasks
299
- const lcTasks = createTasksBatch(db, "lc-plan", [
300
- { ...TASK_DEFAULTS, description: "Implement authentication module", orderIndex: 0 },
301
- { ...TASK_DEFAULTS, description: "Write unit tests", orderIndex: 1 },
302
- { ...TASK_DEFAULTS, description: "Update documentation", orderIndex: 2 },
303
- ]);
304
- assert(lcTasks.length === 3, `created 3 tasks (got ${lcTasks.length})`);
305
-
306
- // 4.4 Start session
307
- const lcSession = startSession(db, {
308
- id: "lc-session-1",
309
- goal: "Complete lifecycle test plan",
310
- planId: "lc-plan",
311
- });
312
- assert(lcSession.planId === "lc-plan", `session linked to plan (got ${lcSession.planId})`);
313
-
314
- // 4.5 Task 1: pending → running
315
- assert(lcTasks[0] !== undefined, "task 0 exists");
316
- assert(lcTasks[1] !== undefined, "task 1 exists");
317
- assert(lcTasks[2] !== undefined, "task 2 exists");
318
- const t1 = lcTasks[0] as NonNullable<(typeof lcTasks)[number]>;
319
- updateTaskStatus(db, t1.id, "running");
320
- const t1Running = db.query("SELECT status FROM plan_tasks WHERE id = ?").get(t1.id) as {
321
- status: string;
322
- } | null;
323
- assert(t1Running?.status === "running", `task 1 → running (got ${t1Running?.status})`);
324
-
325
- // 4.6 Task 1: running → done
326
- updateTaskStatus(db, t1.id, "done", { result: "Authentication module implemented" });
327
- const t1Done = db.query("SELECT status FROM plan_tasks WHERE id = ?").get(t1.id) as {
328
- status: string;
329
- } | null;
330
- assert(t1Done?.status === "done", `task 1 → done (got ${t1Done?.status})`);
331
-
332
- // 4.7 Task 2: pending → done
333
- const t2 = lcTasks[1] as NonNullable<(typeof lcTasks)[number]>;
334
- updateTaskStatus(db, t2.id, "done", { result: "All tests passing" });
335
-
336
- // 4.8 Task 3: pending → done
337
- const t3 = lcTasks[2] as NonNullable<(typeof lcTasks)[number]>;
338
- updateTaskStatus(db, t3.id, "done", { result: "Docs updated" });
339
-
340
- // 4.9 Session checkpoint
341
- const checkpoint = checkpointSession(
342
- db,
343
- "lc-session-1",
344
- { tasksCompleted: 3, totalTasks: 3 },
345
- "All tasks completed successfully",
346
- );
347
- assert(checkpoint !== null, "session checkpoint succeeded");
348
-
349
- // 4.10 Plan → completed
350
- const completedPlan = updatePlanStatus(db, "lc-plan", "completed", { updatedBy: "smoke-e2e" });
351
- assert(completedPlan?.status === "completed", `plan → completed (got ${completedPlan?.status})`);
352
-
353
- // 4.11 Archive the plan
354
- const archiveResult = archivePlan(db, "lc-plan", { memDir: testMemDir });
355
- assert(archiveResult.planId === "lc-plan", "archivePlan planId=lc-plan");
356
- assert(
357
- archiveResult.tasksCount === 3,
358
- `archivePlan tasksCount=3 (got ${archiveResult.tasksCount})`,
359
- );
360
- assert(
361
- archiveResult.sessionsCount === 1,
362
- `archivePlan sessionsCount=1 (got ${archiveResult.sessionsCount})`,
363
- );
364
-
365
- // 4.12 Verify archived_at set on plan, tasks, sessions
366
- const archivedPlan = getPlan(db, "lc-plan");
367
- assert(archivedPlan?.archivedAt !== null, "plan.archived_at is set");
368
-
369
- const archivedTasks = db
370
- .query(
371
- "SELECT COUNT(*) as c FROM plan_tasks WHERE plan_id = 'lc-plan' AND archived_at IS NOT NULL",
372
- )
373
- .get() as { c: number };
374
- assert(archivedTasks.c === 3, `3 tasks have archived_at set (got ${archivedTasks.c})`);
375
-
376
- const archivedSession = db
377
- .query("SELECT archived_at FROM sessions WHERE id = 'lc-session-1'")
378
- .get() as { archived_at: number | null } | null;
379
- assert(archivedSession?.archived_at !== null, "session.archived_at is set");
380
-
381
- // 4.13 listPlans() default excludes archived
382
- const activeOnly = listPlans(db);
383
- assert(!activeOnly.some((p) => p.id === "lc-plan"), "listPlans() excludes archived plan");
384
-
385
- // 4.14 listPlans({ includeArchived: true }) includes archived
386
- const withArchived = listPlans(db, { includeArchived: true });
387
- assert(
388
- withArchived.some((p) => p.id === "lc-plan"),
389
- "listPlans({ includeArchived: true }) includes archived plan",
390
- );
391
-
392
- // 4.15 Memory file exists and is valid
393
- assert(existsSync(archiveResult.filePath), `memory file exists: ${archiveResult.filePath}`);
394
- const mdContent = readFileSync(archiveResult.filePath, "utf-8");
395
- assert(mdContent.length > 500, `markdown > 500 bytes (got ${mdContent.length})`);
396
- assert(mdContent.includes("# Plan:"), "markdown contains '# Plan:'");
397
- assert(mdContent.includes("## Tasks"), "markdown contains '## Tasks'");
398
- assert(mdContent.includes("## Sessions"), "markdown contains '## Sessions'");
399
- assert(mdContent.includes("## Metadata"), "markdown contains '## Metadata'");
400
-
401
- // ═════════════════════════════════════════════════════════════════════════════
402
- // TEST 5: Auto-archive no rompe flow
403
- // ═════════════════════════════════════════════════════════════════════════════
404
- section("Test 5: Auto-archive no rompe flow");
405
-
406
- mkPlan("aa-plan", "test-no-archive", "Auto Archive Test Plan");
407
- const aaResult = archivePlan(db, "aa-plan", { memDir: testMemDir });
408
- assert(
409
- typeof aaResult.filePath === "string" && aaResult.filePath.length > 0,
410
- `archived.filePath is set: ${aaResult.filePath}`,
411
- );
412
- assert(existsSync(aaResult.filePath), "archived file exists on disk");
413
-
414
- // Verify planUpdateStatus returns updated plan for already-completed scenario
415
- mkPlan("aa-plan2", "test-no-archive-2", "Auto Archive Test Plan 2");
416
- updatePlanStatus(db, "aa-plan2", "completed", { updatedBy: "smoke-e2e" });
417
- const aa2Plan = getPlan(db, "aa-plan2");
418
- assert(
419
- aa2Plan?.status === "completed",
420
- `planUpdateStatus returned completed plan (got ${aa2Plan?.status})`,
421
- );
422
-
423
- // ═════════════════════════════════════════════════════════════════════════════
424
- // TEST 6: Idempotencia del archive
425
- // ═════════════════════════════════════════════════════════════════════════════
426
- section("Test 6: Idempotencia del archive");
427
-
428
- // 6.1 Re-archive already archived plan → throws "already archived"
429
- assertThrows(
430
- () => archivePlan(db, "lc-plan", { memDir: testMemDir }),
431
- "already archived",
432
- "archivePlan on already-archived plan throws 'already archived'",
433
- );
434
-
435
- // 6.2 updatePlanStatus on archived plan → should work (not restrictive)
436
- const updatedArchived = updatePlanStatus(db, "lc-plan", "failed", { updatedBy: "smoke-e2e" });
437
- assert(updatedArchived !== null, "updatePlanStatus on archived plan returns plan");
438
- assert(
439
- updatedArchived?.status === "failed",
440
- `archived plan status → failed (got ${updatedArchived?.status})`,
441
- );
442
-
443
- // ═════════════════════════════════════════════════════════════════════════════
444
- // TEST 7: nextTaskForAgent con archived
445
- // ═════════════════════════════════════════════════════════════════════════════
446
- section("Test 7: nextTaskForAgent con archived filter");
447
-
448
- mkPlan("agent-plan", "agent-plan-test", "Agent Plan for nextTask Test");
449
- createTasksBatch(db, "agent-plan", [
450
- { ...TASK_DEFAULTS, description: "Agent task 1", orderIndex: 0, agent: "smith" },
451
- { ...TASK_DEFAULTS, description: "Agent task 2", orderIndex: 1, agent: "smith" },
452
- ]);
453
-
454
- // Archive the plan (auto-archives tasks)
455
- archivePlan(db, "agent-plan", { memDir: testMemDir });
456
-
457
- // Default: should NOT return tasks from archived plan
458
- const archivedAgentTask = nextTaskForAgent(db, "smith", { planId: "agent-plan" });
459
- assert(
460
- archivedAgentTask === null,
461
- `nextTaskForAgent default skips archived plan tasks (got ${archivedAgentTask?.id ?? "null"})`,
462
- );
463
-
464
- // With includeArchived: true → should return pending task
465
- const includedTask = nextTaskForAgent(db, "smith", { planId: "agent-plan", includeArchived: true });
466
- assert(
467
- includedTask !== null && includedTask.planId === "agent-plan",
468
- `nextTaskForAgent with includeArchived=true returns task (got ${includedTask?.id ?? "null"})`,
469
- );
470
- assert(
471
- includedTask?.status === "pending",
472
- `returned task status=pending (got ${includedTask?.status})`,
473
- );
474
-
475
- // ═════════════════════════════════════════════════════════════════════════════
476
- // TEST 8: Migration upgrade path
477
- // ═════════════════════════════════════════════════════════════════════════════
478
- section("Test 8: Migration upgrade path — archived_at columns exist");
479
-
480
- const db8 = new Database(":memory:");
481
- db8.exec("PRAGMA journal_mode = WAL");
482
- db8.exec("PRAGMA foreign_keys = ON");
483
- runMigrations(db8);
484
-
485
- // Verify archived_at exists on all 3 tables
486
- for (const table of ["plans", "plan_tasks", "sessions"]) {
487
- const cols = db8.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
488
- assert(
489
- cols.some((c) => c.name === "archived_at"),
490
- `${table}.archived_at column exists`,
491
- );
492
- }
493
-
494
- // Verify indexes exist
495
- const indexes = db8
496
- .query("SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%archived%'")
497
- .all() as Array<{ name: string }>;
498
- assert(indexes.length === 3, `3 archived indexes exist (got ${indexes.length})`);
499
-
500
- db8.close();
501
-
502
- // ═════════════════════════════════════════════════════════════════════════════
503
- // TEST 9: Re-run de migrations (idempotencia)
504
- // ═════════════════════════════════════════════════════════════════════════════
505
- section("Test 9: Re-run de migrations — idempotencia");
506
-
507
- const db9 = new Database(":memory:");
508
- db9.exec("PRAGMA journal_mode = WAL");
509
- db9.exec("PRAGMA foreign_keys = ON");
510
-
511
- // First run
512
- runMigrations(db9);
513
- const ver9a = db9.query("SELECT MAX(version) as v FROM schema_version").get() as { v: number };
514
- assert(ver9a.v === 5, `first run: schema_version = 5 (got ${ver9a.v})`);
515
-
516
- // Second run — should not fail
517
- let rerunFailed = false;
518
- try {
519
- runMigrations(db9);
520
- } catch (err) {
521
- rerunFailed = true;
522
- console.error(` ✗ runMigrations() re-run threw: ${err}`);
523
- }
524
- assert(!rerunFailed, "runMigrations() re-run did not throw");
525
-
526
- // Verify no duplicate schema_version rows
527
- const versionRows = db9
528
- .query("SELECT version, COUNT(*) as c FROM schema_version GROUP BY version HAVING c > 1")
529
- .all();
530
- assert(versionRows.length === 0, "no duplicate schema_version rows");
531
-
532
- // Verify no duplicate indexes
533
- const idxCount = db9
534
- .query(
535
- "SELECT name, COUNT(*) as c FROM sqlite_master WHERE type='index' GROUP BY name HAVING c > 1",
536
- )
537
- .all();
538
- assert(idxCount.length === 0, "no duplicate indexes");
539
-
540
- // Verify FTS table still works after re-run (use createPlan with db9 directly)
541
- createPlan(db9, {
542
- ...PLAN_DEFAULTS,
543
- id: "rerun-test",
544
- slug: "rerun-test",
545
- title: "Rerun Test Plan for FTS validation",
546
- });
547
- const rerunSearch = searchPlans(db9, "rerun");
548
- assert(
549
- rerunSearch.length === 1,
550
- `post-rerun: searchPlans("rerun") → 1 (got ${rerunSearch.length})`,
551
- );
552
-
553
- db9.close();
554
-
555
- // ═════════════════════════════════════════════════════════════════════════════
556
- // TEST 10: FTS5 cleanup post-archive
557
- // ═════════════════════════════════════════════════════════════════════════════
558
- section("Test 10: FTS5 cleanup post-archive — archived plans excluded from search");
559
-
560
- mkPlan("fts-archive", "fts-archive-marker", "ZZZmarkerxyz unique title");
561
-
562
- // Verify it's findable before archive
563
- const preArchive = searchPlans(db, "ZZZmarkerxyz");
564
- assert(
565
- preArchive.length === 1,
566
- `pre-archive: searchPlans("ZZZmarkerxyz") → 1 (got ${preArchive.length})`,
567
- );
568
-
569
- // Archive it
570
- archivePlan(db, "fts-archive", { memDir: testMemDir });
571
-
572
- // listPlans() default should NOT include it
573
- const postArchiveList = listPlans(db);
574
- assert(
575
- !postArchiveList.some((p) => p.id === "fts-archive"),
576
- "post-archive: listPlans() excludes archived plan",
577
- );
578
-
579
- // searchPlans() default should NOT find it (archived filter in JOIN)
580
- const postArchiveSearch = searchPlans(db, "ZZZmarkerxyz");
581
- assert(
582
- postArchiveSearch.length === 0,
583
- `post-archive: searchPlans("ZZZmarkerxyz") default → 0 (got ${postArchiveSearch.length})`,
584
- );
585
-
586
- // searchPlans with includeArchived: true SHOULD find it
587
- const postArchiveSearchAll = searchPlans(db, "ZZZmarkerxyz", 20, { includeArchived: true });
588
- assert(
589
- postArchiveSearchAll.length === 1,
590
- `post-archive: searchPlans("ZZZmarkerxyz", includeArchived=true) → 1 (got ${postArchiveSearchAll.length})`,
591
- );
592
- assert(
593
- postArchiveSearchAll[0]?.id === "fts-archive",
594
- `includeArchived search returns fts-archive (got ${postArchiveSearchAll[0]?.id})`,
595
- );
596
-
597
- // ═════════════════════════════════════════════════════════════════════════════
598
- // BONUS: findPlansByTag with archived filter
599
- // ═════════════════════════════════════════════════════════════════════════════
600
- section("Bonus: findPlansByTag with archived filter");
601
-
602
- mkPlan("tag-plan", "tag-plan-test", "Tag Test Plan");
603
- addPlanTag(db, "tag-plan", "urgent", "smoke-e2e");
604
-
605
- // Verify tag search works
606
- const tagResults = findPlansByTag(db, "urgent");
607
- assert(
608
- tagResults.some((p) => p.id === "tag-plan"),
609
- "findPlansByTag finds tagged plan",
610
- );
611
-
612
- // Archive and verify filter
613
- archivePlan(db, "tag-plan", { memDir: testMemDir });
614
-
615
- const tagAfterArchive = findPlansByTag(db, "urgent");
616
- assert(
617
- !tagAfterArchive.some((p) => p.id === "tag-plan"),
618
- "findPlansByTag excludes archived plan by default",
619
- );
620
-
621
- const tagWithArchived = findPlansByTag(db, "urgent", 20, { includeArchived: true });
622
- assert(
623
- tagWithArchived.some((p) => p.id === "tag-plan"),
624
- "findPlansByTag with includeArchived=true includes archived plan",
625
- );
626
-
627
- // ═════════════════════════════════════════════════════════════════════════════
628
- // BONUS: findPlansByCategory with archived filter
629
- // ═════════════════════════════════════════════════════════════════════════════
630
- section("Bonus: findPlansByCategory with archived filter");
631
-
632
- mkPlan("cat-plan", "cat-plan-test", "Category Test Plan", { category: "bugfix" as PlanCategory });
633
- archivePlan(db, "cat-plan", { memDir: testMemDir });
634
-
635
- const catResults = findPlansByCategory(db, "bugfix");
636
- assert(
637
- !catResults.some((p) => p.id === "cat-plan"),
638
- "findPlansByCategory excludes archived plan by default",
639
- );
640
-
641
- const catWithArchived = findPlansByCategory(db, "bugfix", 20, { includeArchived: true });
642
- assert(
643
- catWithArchived.some((p) => p.id === "cat-plan"),
644
- "findPlansByCategory with includeArchived=true includes archived plan",
645
- );
646
-
647
- // ═════════════════════════════════════════════════════════════════════════════
648
- // BONUS: plan_progress view with archived filter
649
- // ═════════════════════════════════════════════════════════════════════════════
650
- section("Bonus: plan_progress view — archived tasks excluded from counts");
651
-
652
- mkPlan("prog-plan", "prog-plan-test", "Progress Test Plan");
653
- createTasksBatch(db, "prog-plan", [
654
- { ...TASK_DEFAULTS, description: "Progress task 1", orderIndex: 0 },
655
- { ...TASK_DEFAULTS, description: "Progress task 2", orderIndex: 1 },
656
- ]);
657
-
658
- const progBefore = getPlanProgress(db, "prog-plan");
659
- assert(progBefore.length === 1, "plan_progress returns 1 row");
660
- assert(
661
- progBefore[0]?.totalTasks === 2,
662
- `before archive: totalTasks=2 (got ${progBefore[0]?.totalTasks})`,
663
- );
664
-
665
- archivePlan(db, "prog-plan", { memDir: testMemDir });
666
-
667
- const progAfter = getPlanProgress(db, "prog-plan");
668
- assert(progAfter.length === 1, "plan_progress still returns 1 row after archive");
669
- assert(
670
- progAfter[0]?.totalTasks === 0,
671
- `after archive: totalTasks=0 (archived excluded, got ${progAfter[0]?.totalTasks})`,
672
- );
673
-
674
- // ═════════════════════════════════════════════════════════════════════════════
675
- // Cleanup
676
- // ═════════════════════════════════════════════════════════════════════════════
677
- rmSync(testMemDir, { recursive: true, force: true });
678
- db.close();
679
-
680
- // ═════════════════════════════════════════════════════════════════════════════
681
- // Reporte (caveman)
682
- // ═════════════════════════════════════════════════════════════════════════════
683
- console.log(`\n${"═".repeat(60)}`);
684
- console.log(" REPORTE E2E SMOKE TEST");
685
- console.log(`${"═".repeat(60)}`);
686
- console.log(`Total asserts: ${pass} pass / ${fail} fail`);
687
- console.log(`Total tests: ${pass + fail}`);
688
-
689
- if (fail > 0) {
690
- console.log("\nFALLOS:");
691
- for (const f of failures) {
692
- console.log(` ✗ ${f.test}: ${f.msg}`);
693
- }
694
- console.log("\nANÁLISIS DE CAUSA RAÍZ:");
695
- console.log(` Revisar los ${fail} fallos arriba. Cada uno indica:`);
696
- console.log(" - file:line → scripts/smoke-e2e.ts");
697
- console.log(" - error exacto → ver mensaje de fallo");
698
- console.log(" - causa raíz → probablemente FTS5 config, archive filter, o migration issue");
699
- } else {
700
- console.log("\n✅ TODOS LOS TESTS PASARON - v5 hotfix validado end-to-end");
701
- }
702
-
703
- console.log(`${"═".repeat(60)}`);
704
- process.exit(fail > 0 ? 1 : 0);