11import type { Page } from "@playwright/test" ;
22import { expect , test } from "../../../../fixtures/fixtures" ;
33import { awaitBootstrapTest } from "../../../../helpers/other/await-bootstrap-test" ;
4+ import { getAuthToken } from "../../../../helpers/auth/get-auth-token" ;
45import { cleanOldFolders } from "../../../../helpers/filesystem/clean-old-folders" ;
56import { createProjectThroughSidebar } from "../../../../helpers/flows/create-project-through-sidebar" ;
67import { deleteProject } from "../../../../helpers/flows/delete-project" ;
8+ import { trackCreatedFlows } from "../../../../helpers/flows/track-created-flows" ;
9+ import { unmountEditorForCleanup } from "../../../../helpers/flows/unmount-editor-for-cleanup" ;
710import { navigateSettingsPages } from "../../../../helpers/ui/go-to-settings" ;
811import { openProjectOptions } from "../../../../helpers/ui/project-sidebar" ;
912
@@ -59,6 +62,87 @@ const removeLeftoverRenamedProject = async (page: Page) => {
5962 }
6063} ;
6164
65+ // Ids of what a test created, deleted id-scoped in afterEach (#1376) — never a
66+ // global sweep, which wipes what other workers are building (#515).
67+ //
68+ // Measured on `1.12.0.dev20` over three consecutive runs of this file, counting
69+ // `GET /api/v1/flows/?get_all=true` and `GET /api/v1/projects/` around each:
70+ //
71+ // flows 34 -> 35 -> 36 -> 37 (+1 every run, unbounded)
72+ // projects 1 -> 2 -> 2 -> 2 (+1, then flat)
73+ //
74+ // The two halves fail differently, and the second is the one worth naming. Test
75+ // 2 opens the Basic Prompting template, which creates a flow nothing deletes —
76+ // that leak is monotonic and visible. Test 1 creates TWO projects and only ever
77+ // deletes the one it renames, and that leak reads as zero from run 2 onwards
78+ // only because `cleanOldFolders` at the top of test 1 sweeps the PREVIOUS run's
79+ // leftover. It is not absent, it is absorbed — and #1363 is the incident where
80+ // that absorption stopped working: with the sweep silently deleting nothing,
81+ // `New Project (N)` accumulated inside a single test's own retries.
82+ //
83+ // So the cleanup below is not redundant with `cleanOldFolders`. That helper
84+ // guards against OTHER runs' leftovers; this one stops the file from producing
85+ // them.
86+ const createdProjectIds = new Set < string > ( ) ;
87+
88+ // Flows go through the SHARED tracker (#1108), not a hand-rolled listener. The
89+ // block that would be written here was copied into 51 spec files and drifted on
90+ // four axes; two of them decide whether this file's leak is actually fixed.
91+ // **One of the 51 settles its in-flight body reads** — the id lands a tick after
92+ // the `201`, so a teardown that snapshots immediately drops it and the flow leaks
93+ // anyway. And `cleanup` resolves the bearer itself and **never throws out of
94+ // teardown**: `getAuthToken` throws once its retry budget is spent (a backend
95+ // wedged at teardown, #1077), and a throw in an `afterEach` is a hook error that
96+ // fails an otherwise-green test — the opposite of what cleanup is for. It also
97+ // leaves the editor before deleting (#1288) and reports failures instead of
98+ // swallowing them (#1012).
99+ let flows : ReturnType < typeof trackCreatedFlows > ;
100+
101+ test . beforeEach ( ( { page } ) => {
102+ flows = trackCreatedFlows ( page ) ;
103+ } ) ;
104+
105+ test . afterEach ( async ( { page, request } ) => {
106+ await flows . cleanup ( request ) ;
107+
108+ const projectIds = [ ...createdProjectIds ] ;
109+ createdProjectIds . clear ( ) ;
110+ if ( projectIds . length === 0 ) return ;
111+
112+ // The tracker unmounts only when it has flows to delete, and test 1 has none
113+ // while still holding projects — so the navigation is done here too. Deleting a
114+ // project under a mounted home view makes it refetch a folder that is already
115+ // gone, which the fixture logs as `🚨 Backend Error` (#1023). Idempotent: when
116+ // the tracker already navigated, this is a second `about:blank`.
117+ await unmountEditorForCleanup ( page ) ;
118+
119+ // Same contract as the tracker's own bearer, for the same reason (#1086/#1077):
120+ // caught so a wedged backend cannot turn teardown into a hook error, and NAMED
121+ // rather than degraded silently to an empty token — otherwise the 401s below
122+ // would read as the projects' fault.
123+ let options : { headers : Record < string , string > } | undefined ;
124+ try {
125+ const bearer = await getAuthToken ( request ) ;
126+ options = bearer ? { headers : { Authorization : bearer } } : undefined ;
127+ } catch ( error ) {
128+ console . warn (
129+ `⚠️ cleanup: no auth token — the project deletes below run on the browser ` +
130+ `session alone, so a 401 here is THAT and not the project (#1086/#1077): ${ error } ` ,
131+ ) ;
132+ }
133+
134+ // `deleteProject` treats 404 — the project test 1 already deleted through the
135+ // UI — as the desired end state, and retries the 500 the endpoint answers
136+ // under contention (#965). Reported, never swallowed: a failed cleanup must not
137+ // fail the hook and mask the assertion that already ran, but it must not be
138+ // silent either (#1012).
139+ for ( const id of projectIds ) {
140+ await deleteProject ( request , id , options ) . catch ( ( error ) => {
141+ console . warn ( `⚠️ Orphan project left behind (${ id } ): ${ error } ` ) ;
142+ } ) ;
143+ }
144+ } ) ;
145+
62146test (
63147 "user must be able to see starter projects for mcp servers" ,
64148 { tag : [ "@stable" , "@release" , "@workspace" , "@components" , "@mcp" ] } ,
@@ -85,7 +169,11 @@ test(
85169 // project. Both are needed to address the sidebar entry and its kebab: the
86170 // nightly keys those testids on the project id, 1.11.x on its name (#1363).
87171 const firstProject = await createProjectThroughSidebar ( page ) ;
88- await createProjectThroughSidebar ( page ) ;
172+ const secondProject = await createProjectThroughSidebar ( page ) ;
173+ // Both, not just the renamed one: the second is the project this file used
174+ // to leave behind on every run (#1376).
175+ createdProjectIds . add ( firstProject . id ) ;
176+ createdProjectIds . add ( secondProject . id ) ;
89177
90178 await navigateSettingsPages ( page , "Settings" , "MCP Servers" ) ;
91179
0 commit comments