Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../../../../fixtures/fixtures";
import { awaitBootstrapTest } from "../../../../helpers/other/await-bootstrap-test";
import { getAuthToken } from "../../../../helpers/auth/get-auth-token";
import { cleanOldFolders } from "../../../../helpers/filesystem/clean-old-folders";
import { createProjectThroughSidebar } from "../../../../helpers/flows/create-project-through-sidebar";
import { deleteProject } from "../../../../helpers/flows/delete-project";
import { trackCreatedFlows } from "../../../../helpers/flows/track-created-flows";
import { unmountEditorForCleanup } from "../../../../helpers/flows/unmount-editor-for-cleanup";
import { navigateSettingsPages } from "../../../../helpers/ui/go-to-settings";
import { openProjectOptions } from "../../../../helpers/ui/project-sidebar";

Expand Down Expand Up @@ -59,6 +62,87 @@ const removeLeftoverRenamedProject = async (page: Page) => {
}
};

// Ids of what a test created, deleted id-scoped in afterEach (#1376) — never a
// global sweep, which wipes what other workers are building (#515).
//
// Measured on `1.12.0.dev20` over three consecutive runs of this file, counting
// `GET /api/v1/flows/?get_all=true` and `GET /api/v1/projects/` around each:
//
// flows 34 -> 35 -> 36 -> 37 (+1 every run, unbounded)
// projects 1 -> 2 -> 2 -> 2 (+1, then flat)
//
// The two halves fail differently, and the second is the one worth naming. Test
// 2 opens the Basic Prompting template, which creates a flow nothing deletes —
// that leak is monotonic and visible. Test 1 creates TWO projects and only ever
// deletes the one it renames, and that leak reads as zero from run 2 onwards
// only because `cleanOldFolders` at the top of test 1 sweeps the PREVIOUS run's
// leftover. It is not absent, it is absorbed — and #1363 is the incident where
// that absorption stopped working: with the sweep silently deleting nothing,
// `New Project (N)` accumulated inside a single test's own retries.
//
// So the cleanup below is not redundant with `cleanOldFolders`. That helper
// guards against OTHER runs' leftovers; this one stops the file from producing
// them.
const createdProjectIds = new Set<string>();

// Flows go through the SHARED tracker (#1108), not a hand-rolled listener. The
// block that would be written here was copied into 51 spec files and drifted on
// four axes; two of them decide whether this file's leak is actually fixed.
// **One of the 51 settles its in-flight body reads** — the id lands a tick after
// the `201`, so a teardown that snapshots immediately drops it and the flow leaks
// anyway. And `cleanup` resolves the bearer itself and **never throws out of
// teardown**: `getAuthToken` throws once its retry budget is spent (a backend
// wedged at teardown, #1077), and a throw in an `afterEach` is a hook error that
// fails an otherwise-green test — the opposite of what cleanup is for. It also
// leaves the editor before deleting (#1288) and reports failures instead of
// swallowing them (#1012).
let flows: ReturnType<typeof trackCreatedFlows>;

test.beforeEach(({ page }) => {
flows = trackCreatedFlows(page);
});

test.afterEach(async ({ page, request }) => {
await flows.cleanup(request);

const projectIds = [...createdProjectIds];
createdProjectIds.clear();
if (projectIds.length === 0) return;

// The tracker unmounts only when it has flows to delete, and test 1 has none
// while still holding projects — so the navigation is done here too. Deleting a
// project under a mounted home view makes it refetch a folder that is already
// gone, which the fixture logs as `🚨 Backend Error` (#1023). Idempotent: when
// the tracker already navigated, this is a second `about:blank`.
await unmountEditorForCleanup(page);

// Same contract as the tracker's own bearer, for the same reason (#1086/#1077):
// caught so a wedged backend cannot turn teardown into a hook error, and NAMED
// rather than degraded silently to an empty token — otherwise the 401s below
// would read as the projects' fault.
let options: { headers: Record<string, string> } | undefined;
try {
const bearer = await getAuthToken(request);
options = bearer ? { headers: { Authorization: bearer } } : undefined;
} catch (error) {
console.warn(
`⚠️ cleanup: no auth token — the project deletes below run on the browser ` +
`session alone, so a 401 here is THAT and not the project (#1086/#1077): ${error}`,
);
}

// `deleteProject` treats 404 — the project test 1 already deleted through the
// UI — as the desired end state, and retries the 500 the endpoint answers
// under contention (#965). Reported, never swallowed: a failed cleanup must not
// fail the hook and mask the assertion that already ran, but it must not be
// silent either (#1012).
for (const id of projectIds) {
await deleteProject(request, id, options).catch((error) => {
console.warn(`⚠️ Orphan project left behind (${id}): ${error}`);
});
}
});

test(
"user must be able to see starter projects for mcp servers",
{ tag: ["@stable", "@release", "@workspace", "@components", "@mcp"] },
Expand All @@ -85,7 +169,11 @@ test(
// project. Both are needed to address the sidebar entry and its kebab: the
// nightly keys those testids on the project id, 1.11.x on its name (#1363).
const firstProject = await createProjectThroughSidebar(page);
await createProjectThroughSidebar(page);
const secondProject = await createProjectThroughSidebar(page);
// Both, not just the renamed one: the second is the project this file used
// to leave behind on every run (#1376).
createdProjectIds.add(firstProject.id);
createdProjectIds.add(secondProject.id);

await navigateSettingsPages(page, "Settings", "MCP Servers");

Expand Down
Loading