@@ -97,12 +97,47 @@ test.describe('user smoke: the app works from a user point of view @smoke', () =
9797 } ) ;
9898
9999 test ( 'grow flow stays healthy: + → empty space → connected named node @smoke' , async ( { page } ) => {
100+ test . setTimeout ( 90_000 ) ; // fixture build + reload + settle + grow + undo exceed the 30s default
100101 await login ( page , TEST_USERS . ADMIN ) ;
101- // Wait for a graph to auto-load, then for the force layout to SETTLE — the "+"
102- // grow icon rides on its node, so clicking it while the sim is still moving
103- // (or while nodes overlap) is the historical source of flake. Poll a node's
104- // box until it stops moving rather than guessing with a fixed sleep.
105- await page . locator ( '.graph-container svg .node' ) . first ( ) . waitFor ( { timeout : 20000 } ) . catch ( ( ) => { } ) ;
102+ await page . waitForTimeout ( 1500 ) ;
103+
104+ // Deterministic fixture: a fresh ADMIN-OWNED graph with ONE regular TASK
105+ // node. The auto-selected default graph is non-deterministic and a known
106+ // flake source — the seeded "Development Team" hierarchy uses sheet nodes
107+ // whose first +-click doesn't enter grow mode, and a Welcome graph owned by
108+ // a different user disables grow. Owning our own single-node graph removes
109+ // both. (API truth is used for the +1/-1 deltas below; viewport culling can't
110+ // skew them.)
111+ const graphId = await page . evaluate ( async ( ) => {
112+ const token = localStorage . getItem ( 'authToken' ) ?? '' ;
113+ const post = ( query : string , variables ?: unknown ) =>
114+ fetch ( '/api/graphql' , {
115+ method : 'POST' ,
116+ headers : { 'Content-Type' : 'application/json' , Authorization : `Bearer ${ token } ` } ,
117+ body : JSON . stringify ( { query, variables } ) ,
118+ } ) . then ( ( r ) => r . json ( ) ) ;
119+ const me = await post ( '{ me { id } }' ) ;
120+ const userId = me . data . me . id ;
121+ const g = await post (
122+ `mutation($input: [GraphCreateInput!]!) { createGraphs(input: $input) { graphs { id } } }` ,
123+ { input : [ { name : `Grow Smoke ${ Date . now ( ) } ` , type : 'PROJECT' , status : 'ACTIVE' , createdBy : userId , isShared : true } ] }
124+ ) ;
125+ const gid = g . data . createGraphs . graphs [ 0 ] . id as string ;
126+ await post (
127+ `mutation($input: [WorkItemCreateInput!]!) { createWorkItems(input: $input) { workItems { id } } }` ,
128+ { input : [ { type : 'TASK' , title : 'Grow Seed' , status : 'IN_PROGRESS' , priority : 0.5 , positionX : 0 , positionY : 0 , positionZ : 0 , owner : { connect : { where : { node : { id : userId } } } } , graph : { connect : { where : { node : { id : gid } } } } } ] }
129+ ) ;
130+ return gid ;
131+ } ) ;
132+ expect ( graphId , 'fixture graph created' ) . toBeTruthy ( ) ;
133+
134+ await page . evaluate ( ( gid ) => localStorage . setItem ( 'currentGraphId' , gid ) , graphId ) ;
135+ await page . reload ( ) ;
136+
137+ // Wait for the seed node to render, then for the force layout to SETTLE — the
138+ // "+" grow icon rides on its node, so clicking while the sim is still moving
139+ // is the historical source of flake. Poll the node's box until it stops.
140+ await page . locator ( '.graph-container svg .node' ) . first ( ) . waitFor ( { timeout : 20000 } ) ;
106141 {
107142 let last : { x : number ; y : number } | null = null ;
108143 let stable = 0 ;
@@ -120,22 +155,18 @@ test.describe('user smoke: the app works from a user point of view @smoke', () =
120155 }
121156 }
122157
123- // A graph must be loaded for the grow affordance to exist (the "+" rides on a
124- // node). Use the DOM for that precondition; use the API for the +1/-1 DELTAS
125- // below so viewport culling (offscreen nodes aren't in the DOM) can't skew them.
126- const domNodes = await page . locator ( '.graph-container svg .node' ) . count ( ) ;
127- test . skip ( domNodes === 0 , 'no graph with nodes auto-selected' ) ;
128-
129- const countAll = ( ) => page . evaluate ( async ( ) => {
158+ // Count only THIS fixture graph so the +1 node / +1 edge delta is exact.
159+ const countAll = ( ) => page . evaluate ( async ( gid ) => {
130160 const token = localStorage . getItem ( 'authToken' ) ?? '' ;
131161 const res = await fetch ( '/api/graphql' , {
132162 method : 'POST' ,
133163 headers : { 'Content-Type' : 'application/json' , Authorization : `Bearer ${ token } ` } ,
134- body : JSON . stringify ( { query : `{ workItems { id } edges { id } }` } )
164+ body : JSON . stringify ( { query : `query($g: ID!) { workItems(where: { graph: { id: $g } }) { id } edges(where: { source: { graph: { id: $g } } }) { id } }` , variables : { g : gid } } )
135165 } ) . then ( ( r ) => r . json ( ) ) ;
136166 return { nodes : res . data ?. workItems ?. length ?? - 1 , edges : res . data ?. edges ?. length ?? - 1 } ;
137- } ) ;
167+ } , graphId ) ;
138168 const before = await countAll ( ) ;
169+ expect ( before . nodes , 'fixture starts with exactly the seed node' ) . toBe ( 1 ) ;
139170
140171 // Enter grow mode. Retry the click→hint: a settled layout makes this reliable,
141172 // but a stray overlap can still swallow one click, so re-click until grow mode
@@ -182,31 +213,16 @@ test.describe('user smoke: the app works from a user point of view @smoke', () =
182213 await expect . poll ( async ( ) => ( await countAll ( ) ) . nodes , { timeout : 10000 } ) . toBe ( before . nodes ) ;
183214 expect ( ( await countAll ( ) ) . edges , 'undo must remove the created edge' ) . toBe ( before . edges ) ;
184215
185- // Belt-and-braces cleanup in case undo half-failed (keeps re-runnable)
186- await page . evaluate ( async ( title ) => {
216+ // Tear down the whole fixture graph (edges FIRST — orphan edges break the
217+ // edges query), keeping the suite re-runnable and the DB clean.
218+ await page . evaluate ( async ( gid ) => {
187219 const token = localStorage . getItem ( 'authToken' ) ?? '' ;
188- const find = await fetch ( '/api/graphql' , {
189- method : 'POST' ,
190- headers : { 'Content-Type' : 'application/json' , Authorization : `Bearer ${ token } ` } ,
191- body : JSON . stringify ( { query : `query($t: String!) { workItems(where: { title: $t }) { id } }` , variables : { t : title } } )
192- } ) . then ( ( r ) => r . json ( ) ) ;
193- const id = find . data ?. workItems ?. [ 0 ] ?. id ;
194- if ( ! id ) return ;
195- // Detach edges FIRST — orphan edges break the whole edges query
196- await fetch ( '/api/graphql' , {
197- method : 'POST' ,
198- headers : { 'Content-Type' : 'application/json' , Authorization : `Bearer ${ token } ` } ,
199- body : JSON . stringify ( {
200- query : `mutation($id: ID!) { deleteEdges(where: { OR: [{ source: { id: $id } }, { target: { id: $id } }] }) { nodesDeleted } }` ,
201- variables : { id }
202- } )
203- } ) ;
204- await fetch ( '/api/graphql' , {
205- method : 'POST' ,
206- headers : { 'Content-Type' : 'application/json' , Authorization : `Bearer ${ token } ` } ,
207- body : JSON . stringify ( { query : `mutation($id: ID!) { deleteWorkItems(where: { id: $id }) { nodesDeleted } }` , variables : { id } } )
208- } ) ;
209- } , name ) ;
220+ const post = ( query : string , variables : unknown ) =>
221+ fetch ( '/api/graphql' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' , Authorization : `Bearer ${ token } ` } , body : JSON . stringify ( { query, variables } ) } ) ;
222+ await post ( `mutation($id: ID!) { deleteEdges(where: { source: { graph: { id: $id } } }) { nodesDeleted } }` , { id : gid } ) ;
223+ await post ( `mutation($id: ID!) { deleteWorkItems(where: { graph: { id: $id } }) { nodesDeleted } }` , { id : gid } ) ;
224+ await post ( `mutation($id: ID!) { deleteGraphs(where: { id: $id }) { nodesDeleted } }` , { id : gid } ) ;
225+ } , graphId ) ;
210226 } ) ;
211227
212228 // A brand-new EMPTY graph (the very first thing a user sees after "Create
0 commit comments