Files
app/frontend/tests/search.spec.ts
T
stroblme 29f4d93c2a Search: node settings are indexed, with credentials masked on the server
/search gains a `setting` category, `<node>.<setting>` under its flow with the
node type and the value in a new `detail` field, so Ctrl-K finds which node
uses a broker or a timeout and opens the node. `setting_text` is the one place
that decides what may be said: a reference to a stored secret shows its name
(to superusers only, as the secrets list does), a plain value in an `x-secret`
field or under a key like password, token or api_key is masked and never
indexed, nested credentials in headers or query are masked, long values are
clipped. The global search ranks what contains the query as typed ahead of
loose spellings before it caps a group, which long setting values would
otherwise have crowded out.
2026-10-28 11:07:27 +01:00

135 lines
4.7 KiB
TypeScript

import { expect, test } from "@playwright/test"
import { api, apiPage, deleteAll } from "./utils/api"
/**
* The global search: the only way to reach a node without knowing its flow.
*
* The load-bearing part is what the flat index turns back into — a node hit is
* a flow address with that node in focus, which no list endpoint could have
* answered.
*/
const flowName = `test_search_${Date.now().toString(36)}`
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
test.beforeAll(async ({ browser }) => {
const page = await apiPage(browser)
await api(page, `/flows/${flowName}`, {
method: "PUT",
data: {
name: flowName,
title: "Searchable flow",
nodes: [
{
id: "findme",
type: "python",
title: "Find me",
params: { tuning_gain: 7, api_token: "s3cr3t-token" },
},
],
inputs: [{ spec: { name: "findme_input", dtype: "float" } }],
},
})
await page.close()
})
test.afterAll(async ({ browser }) => {
await deleteAll(browser, [`/flows/${flowName}`])
})
test("finds a node by name and opens its flow on it", async ({ page }) => {
await page.goto("/")
await page.getByRole("button", { name: "Search", exact: true }).click()
await page.getByTestId("global-search-input").fill("findme")
await page.getByRole("option", { name: /Find me/ }).click()
await expect(page).toHaveURL(new RegExp(`/flows/${flowName}\\?node=findme$`))
})
test("finds a flow input and reads it where its label sits", async ({
page,
}) => {
await page.goto("/")
await page.getByRole("button", { name: "Search", exact: true }).click()
await page.getByTestId("global-search-input").fill("findme_input")
await page.getByRole("option", { name: /findme_input/ }).click()
await expect(page).toHaveURL(
new RegExp(`/flows/${flowName}\\?node=input%3A${flowName}\\.findme_input$`),
)
// What clicking the label shows, and the settings one press on.
await expect(page.getByTestId("input-inspector")).toBeVisible()
await expect(page.getByTestId("flow-panel")).toBeHidden()
await page.getByTestId("edit-input").click()
await expect(page.getByTestId("flow-panel")).toBeVisible()
})
test("the label popover rides along when the canvas is panned", async ({
page,
}) => {
const id = `input:${flowName}.findme_input`
await page.goto(`/flows/${flowName}?node=${encodeURIComponent(id)}`)
const popover = page.getByTestId("input-inspector")
const label = page.locator(`.react-flow__node[data-id="${id}"]`)
await expect(popover).toBeVisible()
// Its own entrance moves it too, which would read as part of the offset.
await expect
.poll(() => popover.evaluate((el) => el.getAnimations().length))
.toBe(0)
// Where the popover hangs off the label, which a pan must not change.
const offset = async () => {
const [from, to] = [await label.boundingBox(), await popover.boundingBox()]
return [Math.round(to!.x - from!.x), Math.round(to!.y - from!.y)]
}
const [before, at] = [await offset(), await label.boundingBox()]
const { width, height } = page.viewportSize() ?? { width: 0, height: 0 }
await page.mouse.move(width * 0.8, height * 0.2)
await page.mouse.down()
await page.mouse.move(width * 0.8 - 90, height * 0.2 + 50, { steps: 6 })
await page.mouse.up()
await expect.poll(async () => (await label.boundingBox())!.x).not.toBe(at!.x)
await expect(popover).toBeVisible()
await expect.poll(offset).toEqual(before)
})
test("opens on the keyboard from anywhere", async ({ page }) => {
await page.goto("/dashboards")
// The binding lives in the sidebar, so wait for it to be there to press at.
await expect(
page.getByRole("button", { name: "Search", exact: true }),
).toBeVisible()
await page.keyboard.press("ControlOrMeta+k")
await expect(page.getByTestId("global-search-input")).toBeFocused()
})
test("finds a setting with its value, and never a credential", async ({
page,
}) => {
await page.goto("/")
await page.getByRole("button", { name: "Search", exact: true }).click()
const input = page.getByTestId("global-search-input")
await input.fill("tuning_gain")
const gain = page.getByRole("option", { name: /findme\.tuning_gain/ })
await expect(gain).toContainText(`${flowName} · python · 7`)
// The token is masked on the server, so it never reaches the page.
await input.fill("api_token")
await expect(
page.getByRole("option", { name: /findme\.api_token/ }),
).toContainText("••••")
await expect(page.getByRole("dialog")).not.toContainText("s3cr3t-token")
await input.fill("tuning_gain")
await gain.click()
await expect(page).toHaveURL(new RegExp(`/flows/${flowName}\\?node=findme$`))
})