Files
sencho/e2e/command-palette.spec.ts
T
Anso 98049e3b1c fix(global-search): surface unreachable nodes and harden the command palette (#1253)
* fix(global-search): surface unreachable nodes and harden the command palette

The command palette discarded the cross-node search hook's failedNodes, so a search run while a fleet node was down silently returned partial results with no sign a host was skipped. It now renders an "N nodes unreachable" line and still shows the stacks it could gather.

The shared cross-node search hook fetched every node's stack list and statuses on every keystroke. It now fans out once per search session and filters the cached inventory client-side as the query is refined, cutting per-keystroke fleet traffic. A 200 response with an unparseable status body degrades stacks to unknown instead of failing the whole node.

The palette now owns result matching (cmdk's built-in fuzzy filter is disabled), so Pages, Nodes, and Stacks match by case-insensitive substring in a deterministic order and the 50-row cap applies to the real match set rather than a re-sorted slice.

Adds unit coverage for the hook and palette, plus a Playwright journey spec.

* fix(global-search): clear stale cross-node results when the active node changes

When excludeNodeId changed mid-search (the sidebar switches it on active-node change), the hook started a fresh fanout but left the previous session's inventory and failedNodes visible until the refetch resolved, so the newly active node could briefly appear under the other-nodes results or a stale unreachable warning could persist. The new session now drops prior results synchronously before refetching.
2026-05-29 19:04:39 -04:00

94 lines
4.0 KiB
TypeScript

/**
* Global search command palette E2E - the keyboard-driven navigation journey.
*
* The cross-node failure modes (a remote node returning 502/500 or throwing,
* which drives the "N nodes unreachable" affordance) are covered
* deterministically by the hook unit tests in
* frontend/src/hooks/__tests__/useCrossNodeStackSearch.test.tsx, since they
* cannot be triggered reliably from a single-node E2E environment.
*/
import { test, expect, type Page } from '@playwright/test';
import { loginAs, waitForStacksLoaded } from './helpers';
const PALETTE_INPUT = 'input[placeholder="Search the app..."]';
const PALETTE_STACK = 'e2e-palette-stack';
async function createStackViaUi(page: Page, name: string) {
await page.evaluate(async (n) => {
await fetch(`/api/stacks/${n}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, name);
await page.reload();
await loginAs(page);
await waitForStacksLoaded(page);
await page.getByRole('button', { name: 'Create Stack' }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
await page.locator('#create-stack-name').fill(name);
await page.locator('[role="dialog"]').getByRole('button', { name: 'Create' }).click();
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 8_000 });
}
test.describe('Global search command palette', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page);
await waitForStacksLoaded(page);
});
test('opens with Ctrl+K and closes with Esc', async ({ page }) => {
await page.keyboard.press('Control+k');
await expect(page.locator(PALETTE_INPUT)).toBeVisible({ timeout: 5_000 });
await expect(page.getByRole('dialog').getByText('Pages', { exact: true })).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator(PALETTE_INPUT)).toBeHidden({ timeout: 5_000 });
});
test('opens from the top-bar search trigger', async ({ page }) => {
await page.getByRole('button', { name: 'Open search (Ctrl+K)' }).click();
await expect(page.locator(PALETTE_INPUT)).toBeVisible({ timeout: 5_000 });
await page.keyboard.press('Escape');
await expect(page.locator(PALETTE_INPUT)).toBeHidden({ timeout: 5_000 });
});
test('filters pages by substring and navigates on select', async ({ page }) => {
await page.keyboard.press('Control+k');
const input = page.locator(PALETTE_INPUT);
await expect(input).toBeVisible({ timeout: 5_000 });
await input.fill('fleet');
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Fleet', { exact: true })).toBeVisible();
// A non-matching page is hidden because the palette owns matching.
await expect(dialog.getByText('Home', { exact: true })).toBeHidden();
await dialog.getByText('Fleet', { exact: true }).click();
await expect(input).toBeHidden({ timeout: 5_000 });
});
test('shows "No results." for a query that matches nothing', async ({ page }) => {
await page.keyboard.press('Control+k');
const input = page.locator(PALETTE_INPUT);
await expect(input).toBeVisible({ timeout: 5_000 });
await input.fill('zzzznomatchzzzz');
await expect(page.getByRole('dialog').getByText('No results.')).toBeVisible({ timeout: 8_000 });
});
test('finds a stack by filename and surfaces it in the Stacks group', async ({ page }) => {
await createStackViaUi(page, PALETTE_STACK);
await page.keyboard.press('Control+k');
const input = page.locator(PALETTE_INPUT);
await expect(input).toBeVisible({ timeout: 5_000 });
await input.fill('e2e-palette');
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Stacks', { exact: true })).toBeVisible({ timeout: 8_000 });
await expect(dialog.getByText(new RegExp(PALETTE_STACK))).toBeVisible({ timeout: 8_000 });
// Selecting the stack closes the palette and opens it in the editor.
await dialog.getByText(new RegExp(PALETTE_STACK)).first().click();
await expect(input).toBeHidden({ timeout: 8_000 });
});
});