|
| 1 | +import neo4j, { Driver } from 'neo4j-driver'; |
| 2 | + |
| 3 | +/** |
| 4 | + * Self-healing for the dev Neo4j so test runs never leave the database dirty — |
| 5 | + * even when a run is killed mid-flight (timeout, Ctrl-C) and its per-test |
| 6 | + * cleanup never executes. |
| 7 | + * |
| 8 | + * Heavy suites (scale-sweep, visual-vlm) call sweepTestData() in beforeAll |
| 9 | + * (heal leftovers from a previous interrupted run) AND afterAll (clean up this |
| 10 | + * run). It removes: |
| 11 | + * - graphs whose name carries the test sentinel (or a legacy test prefix), |
| 12 | + * with their WorkItems and Edge nodes, |
| 13 | + * - orphan WorkItems (no BELONGS_TO) — what a half-finished delete leaves, |
| 14 | + * - orphan Edge nodes (missing a source or target) — these 500 the edges |
| 15 | + * query, the original data-integrity incident. |
| 16 | + * |
| 17 | + * It NEVER touches seed/demo graphs (Welcome, Cycle 2, Aquarium, …) — only |
| 18 | + * sentinel/test-named graphs and true orphans. Fully graceful: if Neo4j is |
| 19 | + * unreachable it logs and returns zeros rather than failing the run. |
| 20 | + */ |
| 21 | + |
| 22 | +/** Every test-seeded graph name starts with this so the sweep can find them |
| 23 | + * unambiguously without ever matching a real graph. */ |
| 24 | +export const TEST_GRAPH_PREFIX = '[E2E]'; |
| 25 | + |
| 26 | +// Legacy/explicit test-name patterns (graphs created before the sentinel, or by |
| 27 | +// ad-hoc probes). Anchored so they can't match real graphs. |
| 28 | +const LEGACY_TEST_NAME_REGEX = |
| 29 | + '^(\\[E2E\\]|Scale |VLM |Clone|Parity|PathP|NodeAttach|Contract|CloneFix|CloneProbe|Pop|TP |Empty Smoke|Living E2E|ParityV|Smoke ).*'; |
| 30 | + |
| 31 | +const URI = process.env.NEO4J_URI || 'bolt://localhost:7687'; |
| 32 | +const USER = process.env.NEO4J_USER || 'neo4j'; |
| 33 | +const PASS = process.env.NEO4J_PASSWORD || 'graphdone_password'; |
| 34 | + |
| 35 | +export interface SweepResult { |
| 36 | + testGraphs: number; |
| 37 | + testGraphNodes: number; |
| 38 | + orphanNodes: number; |
| 39 | + orphanEdges: number; |
| 40 | + ok: boolean; |
| 41 | +} |
| 42 | + |
| 43 | +async function deleteInBatches(session: any, matchDelete: string): Promise<number> { |
| 44 | + // matchDelete must be a query of shape: MATCH ... WITH x LIMIT 5000 DETACH DELETE x RETURN count(x) AS c |
| 45 | + let total = 0; |
| 46 | + for (;;) { |
| 47 | + const r = await session.run(matchDelete); |
| 48 | + const c = r.records[0]?.get('c')?.toNumber?.() ?? 0; |
| 49 | + total += c; |
| 50 | + if (c === 0) break; |
| 51 | + } |
| 52 | + return total; |
| 53 | +} |
| 54 | + |
| 55 | +export async function sweepTestData(label = ''): Promise<SweepResult> { |
| 56 | + const result: SweepResult = { testGraphs: 0, testGraphNodes: 0, orphanNodes: 0, orphanEdges: 0, ok: false }; |
| 57 | + let driver: Driver | undefined; |
| 58 | + try { |
| 59 | + driver = neo4j.driver(URI, neo4j.auth.basic(USER, PASS)); |
| 60 | + await driver.verifyConnectivity(); |
| 61 | + const session = driver.session(); |
| 62 | + try { |
| 63 | + // 1) WorkItems + Edge nodes that belong to test-named graphs. |
| 64 | + result.testGraphNodes = await deleteInBatches( |
| 65 | + session, |
| 66 | + `MATCH (g:Graph) WHERE g.name =~ '${LEGACY_TEST_NAME_REGEX}' |
| 67 | + MATCH (g)<-[:BELONGS_TO]-(w:WorkItem) |
| 68 | + OPTIONAL MATCH (w)<-[:EDGE_SOURCE|EDGE_TARGET]-(e:Edge) |
| 69 | + WITH w, e LIMIT 5000 DETACH DELETE e, w RETURN count(w) AS c` |
| 70 | + ); |
| 71 | + // 2) The test-named graphs themselves. |
| 72 | + const g = await session.run( |
| 73 | + `MATCH (g:Graph) WHERE g.name =~ '${LEGACY_TEST_NAME_REGEX}' DETACH DELETE g RETURN count(g) AS c` |
| 74 | + ); |
| 75 | + result.testGraphs = g.records[0]?.get('c')?.toNumber?.() ?? 0; |
| 76 | + // 3) Orphan WorkItems (belong to no graph) — what a killed delete leaves. |
| 77 | + result.orphanNodes = await deleteInBatches( |
| 78 | + session, |
| 79 | + `MATCH (w:WorkItem) WHERE NOT (w)-[:BELONGS_TO]->(:Graph) WITH w LIMIT 5000 DETACH DELETE w RETURN count(w) AS c` |
| 80 | + ); |
| 81 | + // 4) Orphan Edge nodes (missing a source or target) — these break the |
| 82 | + // edges query for everyone. |
| 83 | + result.orphanEdges = await deleteInBatches( |
| 84 | + session, |
| 85 | + `MATCH (e:Edge) WHERE NOT (e)-[:EDGE_SOURCE]->(:WorkItem) OR NOT (e)-[:EDGE_TARGET]->(:WorkItem) WITH e LIMIT 5000 DETACH DELETE e RETURN count(e) AS c` |
| 86 | + ); |
| 87 | + result.ok = true; |
| 88 | + const touched = result.testGraphs + result.testGraphNodes + result.orphanNodes + result.orphanEdges; |
| 89 | + if (touched > 0) { |
| 90 | + // eslint-disable-next-line no-console |
| 91 | + console.log( |
| 92 | + `[db-heal${label ? ' ' + label : ''}] swept ${result.testGraphs} test graphs, ${result.testGraphNodes} their nodes, ${result.orphanNodes} orphan nodes, ${result.orphanEdges} orphan edges` |
| 93 | + ); |
| 94 | + } |
| 95 | + } finally { |
| 96 | + await session.close(); |
| 97 | + } |
| 98 | + } catch (err) { |
| 99 | + // Graceful: never fail the test run because healing couldn't connect. |
| 100 | + // eslint-disable-next-line no-console |
| 101 | + console.warn(`[db-heal] skipped (${err instanceof Error ? err.message.split('\n')[0] : String(err)})`); |
| 102 | + } finally { |
| 103 | + await driver?.close(); |
| 104 | + } |
| 105 | + return result; |
| 106 | +} |
0 commit comments