Skip to content
Merged
Show file tree
Hide file tree
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
4 changes: 2 additions & 2 deletions docs/core-functionality/project-management/bulk-actions.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Project Management – Bulk Actions on Flows

**Last validated:** Langflow 1.11.x
**Last validated:** Langflow 1.12.x (nightly `1.12.0.dev20`)

---

Expand Down Expand Up @@ -52,7 +52,7 @@ The test creates its 3 flows in a **dedicated project/folder** via the REST API

## External dependencies *(required)*

- Home listing UI testids: `list-card`, `checkbox-*`, `flow-name-div`, `download-bulk-btn`, `delete-bulk-btn`, and the folder sidebar entry (`sidebar-nav-<folderName>`).
- Home listing UI testids: `list-card`, `checkbox-*`, `flow-name-div`, `download-bulk-btn`, `delete-bulk-btn`, and the folder sidebar entry, addressed through `helpers/ui/project-sidebar.ts` — `sidebar-nav-<project id>` on the nightly, `sidebar-nav-<name>` on `main` / `1.11.x` (#1363).
- REST API `POST /api/v1/projects/` + `DELETE /api/v1/projects/{id}` for the dedicated folder, `POST /api/v1/flows/` (via the `createFlow` helper, with `folder_id`) for flow creation, and `DELETE /api/v1/flows/{id}` for ID-scoped cleanup (auth via `getAuthToken`).
- No starter template or LLM/provider API key required — flows are created empty via the API.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Project Management – Navigate Between Folders

**Last validated:** Langflow 1.12.x
**Last validated:** Langflow 1.12.x (nightly `1.12.0.dev20`)

---

Expand Down Expand Up @@ -42,7 +42,7 @@ list.
2. Create folder **B** via `POST /api/v1/projects/` (unique name `nav-folderB-<timestamp>`); capture `folderBId`.
3. Create flow **A** via `POST /api/v1/flows/` with `folder_id = folderAId` (unique name `nav-flowA-<timestamp>`); assert the echoed `folder_id` matches; capture `flowAId`.
4. Create flow **B** via `POST /api/v1/flows/` with `folder_id = folderBId` (unique name `nav-flowB-<timestamp>`); assert the echoed `folder_id` matches; capture `flowBId`.
5. Bootstrap the session (`awaitBootstrapTest(page, { skipModal: true })`); assert both `sidebar-nav-<folderA>` and `sidebar-nav-<folderB>` are visible.
5. Bootstrap the session (`awaitBootstrapTest(page, { skipModal: true })`); assert both folders' sidebar entries are visible.
6. `clickProject(folderA)` → assert flow **A** name is visible **and** flow **B** name is hidden (`toBeHidden` / `toHaveCount(0)`).
7. `clickProject(folderB)` → assert flow **B** name is visible **and** flow **A** name is hidden.
8. **Cleanup (finally):** delete `flowAId`, `flowBId` (id-scoped, ignored if already gone), then folders `folderAId`, `folderBId` via `DELETE /api/v1/projects/{id}`.
Expand All @@ -63,7 +63,7 @@ timestamped names make each `getByText` unambiguous under `fullyParallel`.

## External dependencies *(required)*

- Home sidebar testids: `project-sidebar`, `sidebar-nav-<name>` (via `MainPage.clickProject`), `mainpage_title`.
- Home sidebar testids: `project-sidebar`, `mainpage_title`, and the project entry addressed through `helpers/ui/project-sidebar.ts` (via `MainPage.clickProject`) — `sidebar-nav-<project id>` on the nightly, `sidebar-nav-<name>` on `main` / `1.11.x` (#1363).
- Flow listing surface: the flow name rendered on the home grid (asserted via `page.getByText(<flowName>)`, the same surface `folder-crud.spec.ts` uses after `clickProject`).
- REST API: `POST`/`DELETE /api/v1/projects/` (folders), `POST`/`DELETE /api/v1/flows/` with `folder_id` (auth via `getAuthToken`).
- No LLM or provider API key required (model-independent).
Expand Down
29 changes: 17 additions & 12 deletions docs/core-functionality/project-management/folder-crud.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Project Management – Folder (Project) CRUD

**Last validated:** Langflow 1.12.x (nightly `1.12.0.dev9`)
**Last validated:** Langflow 1.12.x (nightly `1.12.0.dev20`)

---

Expand All @@ -17,9 +17,12 @@ the `MainPage` folder helpers (`addProject` / `renameProject` / `deleteProject`
`New Project (N)` whenever one already exists, so the literal was a bet on the
instance having no other folder by that name (#1023).
2. **Rename** — double-clicking the folder and committing a new name updates the
sidebar entry to `sidebar-nav-<new name>`. We assert only the new unique entry
(not the absence of `New Project`), since other specs may create `New Project`
folders in parallel against the same backend.
entry's text. We assert only that this project's entry now reads the new
unique name (not the absence of `New Project`), since other specs may create
`New Project` folders in parallel against the same backend. The text, not the
testid, is the observable: since #1363 the entry's testid is the project id
and does not change with a rename, so asserting on it would pass whether the
rename landed or not.
3. **Delete (empty)** — the folder's more-options → delete → confirm flow removes
the folder and surfaces a "Project deleted successfully" notification.
4. **Delete (with a flow inside)** — a second test sets up a folder containing a
Expand All @@ -44,11 +47,10 @@ which is required for safety under `fullyParallel`.

1. Bootstrap the session without the templates modal (`awaitBootstrapTest(page, { skipModal: true })`).
2. Create the folder with `createProjectThroughSidebar(page)`; it clicks
`add-project-button`, captures the `201` body and asserts
`sidebar-nav-<assigned name>` is visible. The id it returns is what teardown
deletes.
3. Rename the folder to a unique name (`crud-folder-<timestamp>`); assert the new
`sidebar-nav-<name>` is visible (the unique entry alone proves the rename
`add-project-button`, captures the `201` body and asserts the project's
sidebar entry is visible. The id it returns is what teardown deletes.
3. Rename the folder to a unique name (`crud-folder-<timestamp>`); assert the
entry now CONTAINS that name (the unique text alone proves the rename
committed — see note above on parallel `New Project` collisions).
4. Delete the folder via its more-options menu and confirm; assert the
"Project deleted successfully" notification and that the sidebar entry is gone.
Expand All @@ -60,7 +62,7 @@ which is required for safety under `fullyParallel`.
1. Create a folder via `POST /api/v1/folders/`; capture `folderId`.
2. Create a flow inside it via `POST /api/v1/flows/` with `folder_id`; assert the
echoed `folder_id` matches and capture `flowId`.
3. Bootstrap the session (`skipModal: true`); assert `sidebar-nav-<folder>` is
3. Bootstrap the session (`skipModal: true`); assert the folder's sidebar entry is
visible, open it and assert the flow name is listed.
4. Delete the folder through the UI; assert the "Project deleted successfully"
notification and that the sidebar entry is gone.
Expand Down Expand Up @@ -105,8 +107,11 @@ the UI really did delete it) as done. The UI assertions are unchanged.
## External dependencies *(required)*

- Home sidebar testids: `add-project-button`, `project-sidebar`, `input-project`,
`sidebar-nav-<name>`, `more-options-button_<slug>`, `btn-delete-project`, and
the "Delete" confirmation control.
`btn-delete-project`, the "Delete" confirmation control, and the project entry
plus its kebab — both addressed through `helpers/ui/project-sidebar.ts`, which
matches `sidebar-nav-<project id>` / `more-options-button_<project id>` (the
nightly, since upstream `23f91d8587`) and the older `sidebar-nav-<name>` /
`more-options-button_<slug>` still rendered by `main` and `1.11.x` (#1363).
- REST API `POST`/`GET`/`DELETE /api/v1/projects/` (folders) and `/api/v1/flows/`
(auth via `getAuthToken`). `/api/v1/folders/` is a legacy alias of `/projects/`.
- No LLM or provider API key required.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Folder Deletion Integrity

**Last validated:** Langflow 1.12.x (`1.12.0.dev10`)
**Last validated:** Langflow 1.12.x (`1.12.0.dev20`)

---

Expand Down Expand Up @@ -52,10 +52,10 @@ see *The destructive lane* below.
**Test 1 — `deleting a folder should update the folder list immediately`**
1. Create the target folder via `POST /api/v1/projects/` (API setup, so the
deletion target is deterministic and the UI create flow is not re-tested here)
2. `awaitBootstrapTest(page, { skipModal: true })`; assert `sidebar-nav-{name}` is visible
2. `awaitBootstrapTest(page, { skipModal: true })`; assert the folder's sidebar entry is visible
3. Delete it through the UI via `MainPage.deleteProject(name)`
4. Assert the `"Project deleted successfully"` toast
5. Assert `sidebar-nav-{name}` is **no longer visible** — the no-stale-data observable
5. Assert the folder's sidebar entry is **no longer visible** — the no-stale-data observable
6. Assert `add-project-button` is still visible (the page did not break)
7. `finally`: if the UI deletion did not complete, remove the folder through
`deleteProject()` so a failed assertion cannot leak a project (#965)
Expand All @@ -66,7 +66,7 @@ see *The destructive lane* below.
`createProjectThroughSidebar` (`add-project-button`, then rename the entry the
backend reports → type the name → Enter)
3. Assert both sidebar entries exist
4. Delete the alpha folder via its `more-options-button_<name>` → `btn-delete-project` → confirm
4. Delete the alpha folder via its kebab → `btn-delete-project` → confirm
5. Assert the toast, assert the alpha entry is gone and the beta one is still visible
6. Click the beta folder and assert `mainpage_title` renders — the survivor is still usable
7. Delete it (cleanup through the same UI path; afterEach still removes both ids)
Expand All @@ -75,7 +75,7 @@ see *The destructive lane* below.
1. Bootstrap, template round-trip, create `folder-one-<stamp>` through the UI
2. Delete it, assert the toast and that its sidebar entry is gone
3. **Immediately** create `folder-two-<stamp>` through the same UI path
4. Assert it appears and `sidebar-nav-folder-two-<stamp>` is visible — proves no
4. Assert it appears and the `folder-two-<stamp>` entry is visible — proves no
stale-cache collision between the deletion and the next creation
5. Delete it (cleanup)

Expand All @@ -85,7 +85,7 @@ see *The destructive lane* below.
against real content rather than only empty folders
2. Bootstrap with `skipModal: true`
3. Loop: count `sidebar-nav-*` entries in `project-sidebar`, delete the first one
through the UI (hover → `more-options-button_{kebab}` → `btn-delete-project` →
through the UI (hover → its kebab → `btn-delete-project` →
confirm → toast), re-count; repeat until zero
4. Assert the count reached `0`
5. Assert the sidebar shows `"Start creating a project or flow"`
Expand Down Expand Up @@ -427,7 +427,7 @@ seeded on the same instance:
the `project-sidebar` and its `sidebar-nav-*` entries; the loop in test 4 and
every visibility assertion depend on those testids.
- `src/frontend/src/pages/MainPage/components/dropdown/` — the
`more-options-button_{name}` → `btn-delete-project` → confirm path used by
the kebab → `btn-delete-project` → confirm path used by
tests 2, 3 and 4.
- `src/frontend/src/pages/MainPage/pages/emptyPage/` — the empty-project screen:
the `"Start creating a project or flow"` copy and `new_project_btn_empty_page`
Expand Down
8 changes: 5 additions & 3 deletions docs/mcp/server/mcp-server-starter-projects.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# MCP Server — starter projects & project folder CRUD (UI)

**Last validated:** Langflow 1.12.x
**Last validated:** Langflow 1.12.x (nightly `1.12.0.dev20`)

---

Expand Down Expand Up @@ -122,8 +122,10 @@ is out of sync with the project folder), or the duplicate-server guard is gone.
- Settings → MCP Servers page (`mcp_server_name_<index>`, `add-mcp-server-button-page`,
`add-mcp-server-button`, `json-input`) via `helpers/ui/go-to-settings.ts`
(`navigateSettingsPages`).
- Project folder CRUD (`add-project-button`, `more-options-button_<name>`,
Rename/Delete, `input-project`, `sidebar-nav-<name>`).
- Project folder CRUD (`add-project-button`, Rename/Delete, `input-project`, and
the project entry plus its kebab — addressed through
`helpers/ui/project-sidebar.ts`, which matches the id-derived testids of the
nightly and the name-derived ones of `main` / `1.11.x`, #1363).
- `helpers/filesystem/clean-old-folders.ts`, `helpers/filesystem/convert-test-name.ts`,
`helpers/other/await-bootstrap-test.ts`.
- Basic Prompting template + the flow MCP tab (Test 2).
Expand Down
176 changes: 176 additions & 0 deletions tests/helpers/filesystem/clean-old-folders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Unit tests for cleanOldFolders (#1363).
// Run with: npm run test:units
//
// This helper is cleanup, so nothing asserts on it — which is exactly how it
// stopped working without anyone noticing. It drove the sidebar kebab through
// the project's NAME, upstream re-keyed that testid on the project id in
// `23f91d8587`, and from then on the helper deleted nothing and merely timed out
// on a click. The daily's own retries recorded the consequence — `New Project`
// through `New Project (5)` accumulating across six attempts of one test — and
// the run stayed green on that helper, because a cleanup that deletes nothing
// looks identical to a cleanup with nothing to do.
//
// Hence a unit test rather than a one-off measurement: the properties below are
// the ones a reader cannot confirm from a passing spec.
import { test } from "node:test";
import assert from "node:assert/strict";
import type { Page } from "@playwright/test";
import { cleanOldFolders } from "./clean-old-folders";

interface Call {
method: string;
url: string;
}

/**
* A fake `page.request` over a project list. Deletions mutate the list, so a
* test asserts on the END STATE of the account, not only on the calls made —
* the distinction that separates "it issued a DELETE" from "the folder is gone".
*/
function fakePage(
opts: {
projects?: Array<{ id: string; name: string }>;
listStatus?: number;
listBodyIsWrapped?: boolean;
deleteStatus?: number;
} = {},
) {
const {
projects = [],
listStatus = 200,
listBodyIsWrapped = false,
deleteStatus = 204,
} = opts;

const calls: Call[] = [];
const live = [...projects];

const res = (status: number, body: unknown) => ({
ok: () => status >= 200 && status < 300,
status: () => status,
json: async () => body,
text: async () => JSON.stringify(body),
});

const request = {
get: async (url: string) => {
calls.push({ method: "GET", url });
return res(listStatus, listBodyIsWrapped ? { folders: live } : live);
},
delete: async (url: string) => {
calls.push({ method: "DELETE", url });
if (deleteStatus < 300 || deleteStatus === 404) {
const id = url.split("/").pop()!;
const i = live.findIndex((p) => p.id === id);
if (i >= 0) live.splice(i, 1);
}
return res(deleteStatus, {});
},
};

return {
page: { request } as unknown as Page,
calls,
liveProjects: () => live,
};
}

test("deletes every leftover New Project* and leaves everything else alone", async () => {
const fake = fakePage({
projects: [
{ id: "p1", name: "New Project" },
{ id: "p2", name: "New Project (1)" },
{ id: "p3", name: "New Project (5)" },
{ id: "keep1", name: "Starter Project" },
{ id: "keep2", name: "bulk-actions-folder-1754" },
],
});

await cleanOldFolders(fake.page);

assert.deepEqual(
fake.liveProjects().map((p) => p.id),
["keep1", "keep2"],
"the three New Project* leftovers must be gone, the other two untouched",
);
});

test("deletes by ID, never through the sidebar", async () => {
// The regression this file exists for: the helper used to hover the entry and
// click `more-options-button_<slugified name>`. Cleanup runs before the test
// it precedes has asserted anything, so it must not depend on the UI state
// that test is about to read.
const fake = fakePage({ projects: [{ id: "p1", name: "New Project" }] });

await cleanOldFolders(fake.page);

assert.deepEqual(
fake.calls.filter((c) => c.method === "DELETE").map((c) => c.url),
["/api/v1/projects/p1"],
);
});

test("reads the wrapped { folders: [...] } response shape too", async () => {
// `/api/v1/projects/` answers a bare array on some builds and `{ folders }` on
// others; reading only one shape makes the sweep silently a no-op on the other.
const fake = fakePage({
projects: [{ id: "p1", name: "New Project" }],
listBodyIsWrapped: true,
});

await cleanOldFolders(fake.page);

assert.equal(fake.liveProjects().length, 0);
});

test("a failed list is reported and does not throw", async () => {
// Cleanup must never fail the test whose assertions have not run yet.
const fake = fakePage({ projects: [{ id: "p1", name: "New Project" }], listStatus: 500 });

await cleanOldFolders(fake.page);

assert.equal(
fake.calls.filter((c) => c.method === "DELETE").length,
0,
"nothing can be deleted from a list that never arrived",
);
});

test("a project that survives every retry does not abort the sweep", async () => {
// `deleteProject` retries the #965 contention 500 and then throws. One
// undeletable leftover must not stop the others from being cleared.
const fake = fakePage({
projects: [
{ id: "p1", name: "New Project" },
{ id: "p2", name: "New Project (1)" },
],
deleteStatus: 500,
});

await cleanOldFolders(fake.page);

assert.deepEqual(
fake.calls.filter((c) => c.method === "DELETE").map((c) => c.url),
[
// 3 attempts each, per deleteProject's retry contract — both projects tried.
"/api/v1/projects/p1",
"/api/v1/projects/p1",
"/api/v1/projects/p1",
"/api/v1/projects/p2",
"/api/v1/projects/p2",
"/api/v1/projects/p2",
],
);
});

test("a name that merely CONTAINS 'New Project' is not swept", async () => {
// The sweep owns the button's default name, not every project mentioning it —
// a spec's own `keep-New Project-fixture` must survive its neighbours' cleanup.
const fake = fakePage({
projects: [{ id: "p1", name: "keep-New Project-fixture" }],
});

await cleanOldFolders(fake.page);

assert.equal(fake.liveProjects().length, 1);
});
Loading
Loading